Compare commits

...

10 Commits

Author SHA1 Message Date
eewing 82a75411f2 Updated README to clarify branch usage and environment information for the Dev branch.
CI / build (pull_request) Successful in 41s
CI / deploy (pull_request) Has been skipped
2026-03-04 11:00:41 -06:00
tcardoza 214c050c8f Added gitea ci pipeline
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m5s
2026-03-04 10:08:52 -06:00
tcardoza e9c380c35c description fixes and delete button location improvements 2026-03-04 10:01:54 -06:00
tcardoza 738b6aad34 stopped load flickering 2026-03-03 19:02:23 -06:00
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
tcardoza 42531b37a5 bucket picker fix 2026-03-03 12:51:34 -06:00
tcardoza 7ee6b795f7 bucket handling fixes better 2026-03-03 10:18:41 -06:00
tcardoza d9a2b05bb1 bucket handling fixes 2026-03-03 10:15:41 -06:00
23 changed files with 677 additions and 268 deletions
+87
View File
@@ -0,0 +1,87 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
DEPLOY_HOST: "10.10.1.82"
APP_PORT: "4000"
DEPLOY_PATH: "/var/www/tasgrid"
jobs:
build:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
env:
VITE_POCKETBASE_URL: ${{ vars.VITE_POCKETBASE_URL || 'https://pocketbase.ccllc.pro' }}
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
container:
image: node:20-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install curl and ca-certificates
run: apt-get update -qq && apt-get install -y curl ca-certificates
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
echo "PATH=$HOME/.bun/bin:$PATH" >> $GITHUB_ENV
- name: Install deploy tools
run: apt-get update -qq && apt-get install -y rsync openssh-client sshpass
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
env:
VITE_POCKETBASE_URL: ${{ secrets.VITE_POCKETBASE_URL || vars.VITE_POCKETBASE_URL || 'https://pocketbase.ccllc.pro' }}
- name: Verify build output
run: |
ls -la
test -d dist || (echo "Missing dist/ (Vite output). Check Build step." && exit 1)
- name: Setup SSH known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} -H ${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy via rsync
env:
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }}" \
dist/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }}/
- name: Restart app on server
env:
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
+4
View File
@@ -1,3 +1,7 @@
> **Branch notice:** This README belongs to the `Dev` branch and describes the **dev environment**. For live/production information, use the `main` branch.
---
## 🚀 Getting Started
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
+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(() => {
+7 -1
View File
@@ -20,6 +20,7 @@ interface LayoutProps {
export const Layout: Component<LayoutProps> = (props) => {
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
@@ -64,7 +65,11 @@ export const Layout: Component<LayoutProps> = (props) => {
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
onMouseLeave={() => !isSidebarLocked() && setIsSidebarPeeking(false)}>
onMouseLeave={() => {
if (!isSidebarLocked() && !isDropdownOpen()) {
setIsSidebarPeeking(false);
}
}}>
<div class="relative flex-1 flex flex-col min-h-0">
{/* Task Sidebar Wrapper */}
@@ -82,6 +87,7 @@ export const Layout: Component<LayoutProps> = (props) => {
isPeeking={isSidebarPeeking()}
setIsPeeking={setIsSidebarPeeking}
isEmbed={true}
onDropdownOpenChange={setIsDropdownOpen}
/>
</div>
+5 -1
View File
@@ -300,6 +300,7 @@ export const Sidebar: Component<{
isPeeking: boolean;
setIsPeeking: (v: boolean) => void;
isEmbed?: boolean;
onDropdownOpenChange?: (open: boolean) => void;
}> = (props) => {
const [switcherOpen, setSwitcherOpen] = createSignal(false);
@@ -374,7 +375,10 @@ export const Sidebar: Component<{
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={setSwitcherOpen}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
</div>
+27 -28
View File
@@ -1,4 +1,4 @@
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
import { type Component, createSignal, createMemo, onCleanup, onMount } from "solid-js";
import { store, currentTaskContext } from "@/store";
import { Plus, X } from "lucide-solid";
import { Button } from "@/components/ui/button";
@@ -7,37 +7,36 @@ import { QuickEntryForm } from "./QuickEntryForm";
export const QuickEntry: Component = () => {
const [isOpen, setIsOpen] = createSignal(false);
const [initialTags, setInitialTags] = createSignal<string[]>([]);
// Autofill tags based on context when opening
createEffect(() => {
if (isOpen()) {
const tags: string[] = [];
const ctx = currentTaskContext();
if (store.isNotepadMode) {
// Not in a specific context, but in Notes mode.
// We use global window variable set by Layout to get the active note id
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note && note.title) {
const noteTagName = note.title.trim();
if (noteTagName) tags.push(noteTagName);
}
}
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
// Bucket View: Autofill bucket name
const bucketName = ctx.name;
if (bucketName) tags.push(bucketName);
} else if ('userId' in ctx) {
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
const userName = ctx.name;
if (userName) tags.push(userName);
const initialTags = createMemo(() => {
if (!isOpen()) return [];
const tags: string[] = [];
const ctx = currentTaskContext();
if (store.isNotepadMode) {
// Not in a specific context, but in Notes mode.
// We use global window variable set by Layout to get the active note id
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note && note.title) {
const noteTagName = note.title.trim();
if (noteTagName) tags.push(noteTagName);
}
}
setInitialTags(tags);
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
// Bucket View: Autofill bucket name
const bucketName = ctx.name;
if (bucketName) tags.push(`@${bucketName}`);
} else if ('userId' in ctx) {
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
const userName = ctx.name;
if (userName) tags.push(`@${userName}`);
}
}
return tags;
});
const handleKeyDown = (e: KeyboardEvent) => {
+15
View File
@@ -43,6 +43,21 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
}
});
// Reactive sync from props (handles context changes while open)
createEffect(() => {
const initial = props.initialTags || [];
if (initial.length > 0) {
setTags(prev => {
// Merge initial tags if not already present
const next = [...prev];
initial.forEach(t => {
if (!next.includes(t)) next.push(t);
});
return next;
});
}
});
// Reactive shorthand parsing
createEffect(() => {
const text = input();
+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)}
>
+45 -44
View File
@@ -417,58 +417,59 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
/>
</Suspense>
{/* Tags at the bottom */}
<div class="mt-auto pt-6 border-t border-border/50">
<div class="flex flex-wrap items-center gap-3">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
<div class="flex flex-wrap items-center gap-1.5 min-w-0 flex-1">
<TagPicker
selectedTags={visibleTags()}
onTagsChange={updateTags}
/>
<div class="flex flex-wrap items-center gap-1.5">
<For each={visibleTags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
</div>
{/* Sticky Tags Area */}
<div class="px-6 py-3 border-t border-border/50 shrink-0 bg-card/80 backdrop-blur-sm z-10 w-full overflow-hidden">
<div class="flex flex-wrap items-center gap-3">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
<TagPicker
selectedTags={visibleTags()}
onTagsChange={updateTags}
/>
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
<For each={visibleTags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => updateTags(visibleTags().filter(t => t !== tag))}
>
<div
class="w-1.5 h-1.5 rounded-full"
style={{
"background-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}15` : undefined;
})(),
"border-color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? `${color}30` : undefined;
})(),
"color": (() => {
const def = store.tagDefinitions.find(d => d.name === tag);
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
onClick={() => updateTags(visibleTags().filter(t => t !== tag))}
>
<div
class="w-1.5 h-1.5 rounded-full"
style={{
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
}}
/>
{tag}
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
</Badge>
)}
</For>
</div>
/>
{tag}
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
</Badge>
)}
</For>
</div>
</div>
</div>
</div>
{/* Sticky Footer Actions */}
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm">
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm z-20 w-full relative">
{/* Delete button (persistent) */}
<Button
variant="ghost"
+1 -1
View File
@@ -239,7 +239,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Handle full screen images as before
return (
<div class="relative w-full h-full flex flex-col">
<div class="relative w-full flex flex-col flex-1">
<div
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
onClick={() => {
+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 {
+92 -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 () => {
@@ -979,7 +1035,14 @@ export const initStore = async () => {
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
allIncomplete.forEach((r: any) => {
if (r.tags?.includes("__template__")) return;
const existing = taskMap.get(r.id);
// Prevent DOM rebuilding/flickering for tasks that haven't changed
if (existing && existing.updated === r.updated) {
return; // Keeps the exact same reference in the map
}
// If existing has content, keep it. Otherwise use new record (which likely has no content yet)
const newTask = mapRecordToTask(r);
if (existing && existing.content) {
@@ -1387,16 +1450,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 +1538,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 +2016,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>
+112 -66
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,7 +217,7 @@ 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)}
@@ -234,36 +227,74 @@ export const NotepadView: Component<{
</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,39 +323,54 @@ 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>
<Show when={!props.hideNavigation}>
{/* Sticky Full-Width Mobile Close Footer */}
<div class="md:hidden sticky bottom-0 z-[60] p-4 bg-background/95 backdrop-blur-sm border-t border-border flex justify-end shrink-0 w-full shadow-[0_-10px_40px_-5px_hsl(var(--background))]">
<Button
variant="secondary"
class="w-full h-11 rounded-xl text-sm font-bold shadow-sm"
onClick={() => props.setSelectedNoteId(null)}
>
<X size={16} class="mr-2" />
Close Note
</Button>
</div>
</Show>
</>
);
}}
</Show>
</Show >
</div>
</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>