Compare commits

...

3 Commits

Author SHA1 Message Date
tcardoza f1200a2c78 added task transitions to movement 2026-03-03 18:54:40 -06:00
tcardoza 15f518c58f quickload list implemented 2026-03-03 18:29:42 -06:00
tcardoza e5d579051d Notes UI improvements 2026-03-03 17:45:50 -06:00
15 changed files with 480 additions and 192 deletions
+3
View File
@@ -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=="],
+1
View File
@@ -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
View File
@@ -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(() => {
+4 -3
View File
@@ -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)}
>
+1
View File
@@ -11,6 +11,7 @@ const Toaster = (props: ToasterProps) => {
theme={theme() as ToasterProps["theme"]}
class="toaster group"
position="top-center"
closeButton
toastOptions={{
classes: {
toast:
+65
View File
@@ -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 };
}
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>