Compare commits
10 Commits
8020e16ca1
...
Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a75411f2 | |||
| 214c050c8f | |||
| e9c380c35c | |||
| 738b6aad34 | |||
| f1200a2c78 | |||
| 15f518c58f | |||
| e5d579051d | |||
| 42531b37a5 | |||
| 7ee6b795f7 | |||
| d9a2b05bb1 |
@@ -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)"'
|
||||||
@@ -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
|
## 🚀 Getting Started
|
||||||
|
|
||||||
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
|
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "tasgrid",
|
"name": "tasgrid",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@kobalte/core": "^0.13.11",
|
"@kobalte/core": "^0.13.11",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "^3.18.0",
|
"@tiptap/core": "^3.18.0",
|
||||||
@@ -297,6 +298,8 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
"@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/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=="],
|
"@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)"
|
"deploy": "git pull && bun install && bun run build && (pm2 restart tasgrid || bun run start-prod)"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@kobalte/core": "^0.13.11",
|
"@kobalte/core": "^0.13.11",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "^3.18.0",
|
"@tiptap/core": "^3.18.0",
|
||||||
|
|||||||
+5
-1
@@ -30,12 +30,16 @@ const App: Component = () => {
|
|||||||
window.addEventListener('popstate', handleLocChange);
|
window.addEventListener('popstate', handleLocChange);
|
||||||
window.addEventListener('hashchange', handleLocChange);
|
window.addEventListener('hashchange', handleLocChange);
|
||||||
|
|
||||||
|
let isFirstRun = true;
|
||||||
const removeListener = pb.authStore.onChange((token) => {
|
const removeListener = pb.authStore.onChange((token) => {
|
||||||
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
||||||
|
const wasAuthenticated = isAuthenticated();
|
||||||
setIsAuthenticated(!!token);
|
setIsAuthenticated(!!token);
|
||||||
if (token) {
|
|
||||||
|
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||||
initStore();
|
initStore();
|
||||||
}
|
}
|
||||||
|
isFirstRun = false;
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface LayoutProps {
|
|||||||
export const Layout: Component<LayoutProps> = (props) => {
|
export const Layout: Component<LayoutProps> = (props) => {
|
||||||
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
||||||
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
|
||||||
|
|
||||||
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
|
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
|
||||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
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"
|
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
|
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">
|
<div class="relative flex-1 flex flex-col min-h-0">
|
||||||
{/* Task Sidebar Wrapper */}
|
{/* Task Sidebar Wrapper */}
|
||||||
@@ -82,6 +87,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
|||||||
isPeeking={isSidebarPeeking()}
|
isPeeking={isSidebarPeeking()}
|
||||||
setIsPeeking={setIsSidebarPeeking}
|
setIsPeeking={setIsSidebarPeeking}
|
||||||
isEmbed={true}
|
isEmbed={true}
|
||||||
|
onDropdownOpenChange={setIsDropdownOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export const Sidebar: Component<{
|
|||||||
isPeeking: boolean;
|
isPeeking: boolean;
|
||||||
setIsPeeking: (v: boolean) => void;
|
setIsPeeking: (v: boolean) => void;
|
||||||
isEmbed?: boolean;
|
isEmbed?: boolean;
|
||||||
|
onDropdownOpenChange?: (open: boolean) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [switcherOpen, setSwitcherOpen] = createSignal(false);
|
const [switcherOpen, setSwitcherOpen] = createSignal(false);
|
||||||
|
|
||||||
@@ -374,7 +375,10 @@ export const Sidebar: Component<{
|
|||||||
isLocked={props.isLocked}
|
isLocked={props.isLocked}
|
||||||
isPeeking={props.isPeeking}
|
isPeeking={props.isPeeking}
|
||||||
setIsPeeking={props.setIsPeeking}
|
setIsPeeking={props.setIsPeeking}
|
||||||
onOpenChange={setSwitcherOpen}
|
onOpenChange={(open) => {
|
||||||
|
setSwitcherOpen(open);
|
||||||
|
props.onDropdownOpenChange?.(open);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { store, currentTaskContext } from "@/store";
|
||||||
import { Plus, X } from "lucide-solid";
|
import { Plus, X } from "lucide-solid";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -7,37 +7,36 @@ import { QuickEntryForm } from "./QuickEntryForm";
|
|||||||
|
|
||||||
export const QuickEntry: Component = () => {
|
export const QuickEntry: Component = () => {
|
||||||
const [isOpen, setIsOpen] = createSignal(false);
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
const [initialTags, setInitialTags] = createSignal<string[]>([]);
|
|
||||||
|
|
||||||
// Autofill tags based on context when opening
|
// Autofill tags based on context when opening
|
||||||
createEffect(() => {
|
const initialTags = createMemo(() => {
|
||||||
if (isOpen()) {
|
if (!isOpen()) return [];
|
||||||
const tags: string[] = [];
|
|
||||||
const ctx = currentTaskContext();
|
const tags: string[] = [];
|
||||||
if (store.isNotepadMode) {
|
const ctx = currentTaskContext();
|
||||||
// Not in a specific context, but in Notes mode.
|
|
||||||
// We use global window variable set by Layout to get the active note id
|
if (store.isNotepadMode) {
|
||||||
const activeNoteId = (window as any)._activeNoteId;
|
// Not in a specific context, but in Notes mode.
|
||||||
if (activeNoteId) {
|
// We use global window variable set by Layout to get the active note id
|
||||||
const note = store.notes.find(n => n.id === activeNoteId);
|
const activeNoteId = (window as any)._activeNoteId;
|
||||||
if (note && note.title) {
|
if (activeNoteId) {
|
||||||
const noteTagName = note.title.trim();
|
const note = store.notes.find(n => n.id === activeNoteId);
|
||||||
if (noteTagName) tags.push(noteTagName);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -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
|
// Reactive shorthand parsing
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const text = input();
|
const text = input();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/u
|
|||||||
import { getStatusOptionsForTask } from "@/lib/constants";
|
import { getStatusOptionsForTask } from "@/lib/constants";
|
||||||
import { StatusCircle } from "./StatusCircle";
|
import { StatusCircle } from "./StatusCircle";
|
||||||
|
|
||||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||||
|
|
||||||
@@ -58,8 +58,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={cn(
|
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",
|
"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.task.completed && "opacity-60",
|
||||||
|
props.isShaking && "animate-task-shake bg-accent/20"
|
||||||
)}
|
)}
|
||||||
onClick={() => setActiveTaskId(props.task.id)}
|
onClick={() => setActiveTaskId(props.task.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -417,58 +417,59 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* Tags at the bottom */}
|
</div>
|
||||||
<div class="mt-auto pt-6 border-t border-border/50">
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
{/* Sticky Tags Area */}
|
||||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
<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-1.5 min-w-0 flex-1">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<TagPicker
|
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
||||||
selectedTags={visibleTags()}
|
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
|
||||||
onTagsChange={updateTags}
|
<TagPicker
|
||||||
/>
|
selectedTags={visibleTags()}
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
onTagsChange={updateTags}
|
||||||
<For each={visibleTags()}>
|
/>
|
||||||
{(tag) => (
|
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
|
||||||
<Badge
|
<For each={visibleTags()}>
|
||||||
variant="secondary"
|
{(tag) => (
|
||||||
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"
|
<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={{
|
style={{
|
||||||
"background-color": (() => {
|
"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"
|
||||||
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))}
|
/>
|
||||||
>
|
{tag}
|
||||||
<div
|
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
||||||
class="w-1.5 h-1.5 rounded-full"
|
</Badge>
|
||||||
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"
|
</For>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{tag}
|
|
||||||
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sticky Footer Actions */}
|
{/* 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) */}
|
{/* Delete button (persistent) */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
|
|
||||||
// Handle full screen images as before
|
// Handle full screen images as before
|
||||||
return (
|
return (
|
||||||
<div class="relative w-full h-full flex flex-col">
|
<div class="relative w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
|
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const Toaster = (props: ToasterProps) => {
|
|||||||
theme={theme() as ToasterProps["theme"]}
|
theme={theme() as ToasterProps["theme"]}
|
||||||
class="toaster group"
|
class="toaster group"
|
||||||
position="top-center"
|
position="top-center"
|
||||||
|
closeButton
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classes: {
|
classes: {
|
||||||
toast:
|
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;
|
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 {
|
:root {
|
||||||
|
|||||||
+92
-31
@@ -30,7 +30,7 @@ export interface Task {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
ownerId: string | null; // Add ownerId to interface
|
ownerId: string | null; // Add ownerId to interface
|
||||||
content?: string; // HTML content from Tiptap
|
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
|
created: string; // ISO string from PB
|
||||||
updated: string; // ISO string from PB
|
updated: string; // ISO string from PB
|
||||||
recurrence?: {
|
recurrence?: {
|
||||||
@@ -202,6 +202,7 @@ interface TaskStore {
|
|||||||
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
isNotepadMode: boolean;
|
isNotepadMode: boolean;
|
||||||
|
quickloadTasks: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial empty state
|
// Initial empty state
|
||||||
@@ -227,7 +228,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
buckets: [],
|
buckets: [],
|
||||||
subscribedBuckets: [],
|
subscribedBuckets: [],
|
||||||
notes: [],
|
notes: [],
|
||||||
isNotepadMode: false
|
isNotepadMode: false,
|
||||||
|
quickloadTasks: []
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matchesFilter = (task: Task) => {
|
export const matchesFilter = (task: Task) => {
|
||||||
@@ -336,6 +338,38 @@ createRoot(() => {
|
|||||||
localStorage.setItem(key, JSON.stringify(store));
|
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 --
|
// -- Calibration --
|
||||||
@@ -762,6 +796,45 @@ export const initStore = async () => {
|
|||||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||||
setStore("isInitializing", true);
|
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
|
// 0. Synchronous hydration from localStorage for "instant" first paint
|
||||||
const key = getStorageKey();
|
const key = getStorageKey();
|
||||||
if (key) {
|
if (key) {
|
||||||
@@ -795,7 +868,8 @@ export const initStore = async () => {
|
|||||||
uWeight: prefs.uWeight || 1.0,
|
uWeight: prefs.uWeight || 1.0,
|
||||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||||
prefId: userId,
|
prefId: userId,
|
||||||
subscribedBuckets: user.subscribedBuckets || []
|
subscribedBuckets: user.subscribedBuckets || [],
|
||||||
|
quickloadTasks: prefs.quickloadTasks || []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (prefErr) {
|
} catch (prefErr) {
|
||||||
@@ -859,24 +933,6 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// --- WINDOWED SYNC STRATEGY ---
|
// --- 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
|
// STAGE 2 & 3: Background Sync
|
||||||
const backgroundSync = async () => {
|
const backgroundSync = async () => {
|
||||||
@@ -979,7 +1035,14 @@ export const initStore = async () => {
|
|||||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||||
allIncomplete.forEach((r: any) => {
|
allIncomplete.forEach((r: any) => {
|
||||||
if (r.tags?.includes("__template__")) return;
|
if (r.tags?.includes("__template__")) return;
|
||||||
|
|
||||||
const existing = taskMap.get(r.id);
|
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)
|
// If existing has content, keep it. Otherwise use new record (which likely has no content yet)
|
||||||
const newTask = mapRecordToTask(r);
|
const newTask = mapRecordToTask(r);
|
||||||
if (existing && existing.content) {
|
if (existing && existing.content) {
|
||||||
@@ -1387,16 +1450,13 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
|||||||
delete pbUpdates.ownerId;
|
delete pbUpdates.ownerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalUpdates.deletedAt !== undefined) {
|
if ("deletedAt" in finalUpdates) {
|
||||||
// PB expects a date string or null for date fields usually,
|
// PB expects a date string or null for date fields.
|
||||||
// but if we defined deletedAt as a 'date' field in PB.
|
// Using finalUpdates.deletedAt check to distinguish between soft-delete (timestamp) and restore (null/falsy)
|
||||||
// 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 (finalUpdates.deletedAt) {
|
if (finalUpdates.deletedAt) {
|
||||||
pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString();
|
pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString();
|
||||||
} else {
|
} else {
|
||||||
pbUpdates.deletedAt = null; // restore
|
pbUpdates.deletedAt = null; // restore task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,8 +1538,8 @@ export const removeTask = (id: string) => {
|
|||||||
|
|
||||||
export const restoreTask = (id: string) => {
|
export const restoreTask = (id: string) => {
|
||||||
// updateTask handles both optimistic update and PB sync
|
// updateTask handles both optimistic update and PB sync
|
||||||
// Pass undefined for deletedAt, updateTask will convert to null for PB
|
// Pass null for deletedAt to explicitly clear it in PB
|
||||||
updateTask(id, { deletedAt: undefined });
|
updateTask(id, { deletedAt: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTaskPermanently = async (id: string) => {
|
export const deleteTaskPermanently = async (id: string) => {
|
||||||
@@ -1956,7 +2016,8 @@ export const syncPreferences = async () => {
|
|||||||
pWeight: store.pWeight,
|
pWeight: store.pWeight,
|
||||||
uWeight: store.uWeight,
|
uWeight: store.uWeight,
|
||||||
matrixScaleDays: store.matrixScaleDays,
|
matrixScaleDays: store.matrixScaleDays,
|
||||||
tagDefinitions: store.tagDefinitions
|
tagDefinitions: store.tagDefinitions,
|
||||||
|
quickloadTasks: store.quickloadTasks
|
||||||
};
|
};
|
||||||
|
|
||||||
await pb.collection('users').update(userId, {
|
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 { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const CriticalView: Component = () => {
|
export const CriticalView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
return [...store.tasks]
|
|
||||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
.sort((a, b) => {
|
sourceTasks,
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
(a, b) => {
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
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 (
|
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>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">✨</span>
|
<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 { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const DigInView: Component = () => {
|
export const DigInView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
return [...store.tasks]
|
|
||||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
.sort((a, b) => {
|
sourceTasks,
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
(a, b) => {
|
||||||
// Primary: Size descending (largest first)
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
const sizeA = a.size ?? 5;
|
// Primary: Size descending (largest first)
|
||||||
const sizeB = b.size ?? 5;
|
const sizeA = a.size ?? 5;
|
||||||
if (sizeA !== sizeB) return sizeB - sizeA;
|
const sizeB = b.size ?? 5;
|
||||||
// Secondary: Combined score descending (higher focus first)
|
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
// 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 (
|
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>
|
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">⛏️</span>
|
<span class="text-2xl">⛏️</span>
|
||||||
|
|||||||
+112
-66
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
|||||||
import { store, renameTagDefinition } from "@/store";
|
import { store, renameTagDefinition } from "@/store";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { type Note } from "@/store";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TaskEditor } from "@/components/TaskEditor";
|
import { TaskEditor } from "@/components/TaskEditor";
|
||||||
@@ -17,6 +17,7 @@ export const NotepadView: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [isLinking, setIsLinking] = createSignal(false);
|
const [isLinking, setIsLinking] = createSignal(false);
|
||||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||||
|
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(true);
|
||||||
|
|
||||||
const currentUserId = pb.authStore.model?.id;
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
|
||||||
@@ -173,15 +174,7 @@ export const NotepadView: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Editor Area */}
|
{/* 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">
|
<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">
|
||||||
{/* 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="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -224,7 +217,7 @@ export const NotepadView: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Instance */}
|
{/* 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
|
<TaskEditor
|
||||||
content={note().content}
|
content={note().content}
|
||||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||||
@@ -234,36 +227,74 @@ export const NotepadView: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Linked Tasks Sidebar */}
|
{/* 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={cn(
|
||||||
<div class="p-4 border-b border-border bg-card/50 flex flex-col gap-3">
|
"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",
|
||||||
<div class="flex items-center justify-between">
|
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"
|
||||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
)}
|
||||||
<Link size={12} />
|
onClick={() => {
|
||||||
Linked Tasks
|
if (!isLinkedTasksOpen()) {
|
||||||
</h3>
|
setIsLinkedTasksOpen(true);
|
||||||
<div class="flex items-center gap-1">
|
}
|
||||||
<Button
|
}}>
|
||||||
size="icon"
|
<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")}>
|
||||||
variant="ghost"
|
<div
|
||||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
class="flex items-center justify-between cursor-pointer group"
|
||||||
onClick={() => setIsLinking(!isLinking())}
|
onClick={(e) => {
|
||||||
title="Link Existing Task"
|
e.stopPropagation();
|
||||||
>
|
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
||||||
<Link size={14} />
|
}}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
||||||
size="sm"
|
<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")} />
|
||||||
variant="ghost"
|
<Show when={isLinkedTasksOpen()} fallback={
|
||||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
<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">
|
||||||
onClick={openQuickEntry}
|
<Link size={10} class="mr-2 rotate-90" />
|
||||||
title="Create Task via Quick Entry"
|
Linked Tasks
|
||||||
>
|
</div>
|
||||||
+ Task
|
}>
|
||||||
</Button>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Show when={isLinking()}>
|
<Show when={isLinkedTasksOpen() && isLinking()}>
|
||||||
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
<Show when={isLinkedTasksOpen()}>
|
||||||
<For each={linkedTasks()} fallback={
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
<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">
|
<For each={linkedTasks()} fallback={
|
||||||
<Link size={24} class="opacity-20" />
|
<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">
|
||||||
<p>No tasks linked yet.</p>
|
<Link size={24} class="opacity-20" />
|
||||||
<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>
|
<p>No tasks linked yet.</p>
|
||||||
</div>
|
<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>
|
||||||
}>
|
|
||||||
{(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>
|
</div>
|
||||||
)}
|
}>
|
||||||
</For>
|
{(task) => (
|
||||||
</div>
|
<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>
|
</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>
|
||||||
</div >
|
</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 { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const PriorityView: Component = () => {
|
export const PriorityView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
return [...store.tasks]
|
|
||||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
.sort((a, b) => {
|
sourceTasks,
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
(a, b) => {
|
||||||
// Use combined score to account for priority, urgency, and tags
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
return getCombinedScore(b) - getCombinedScore(a);
|
||||||
});
|
},
|
||||||
|
300
|
||||||
|
);
|
||||||
|
|
||||||
|
let listRef: HTMLDivElement | undefined;
|
||||||
|
onMount(() => {
|
||||||
|
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,13 +28,17 @@ export const PriorityView: Component = () => {
|
|||||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">🎯</span>
|
<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 { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const ProgressView: Component = () => {
|
export const ProgressView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
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;
|
|
||||||
|
|
||||||
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
// Assuming status 0-9 are active states, 10 is completed (handled above)
|
sourceTasks,
|
||||||
if (a.status !== b.status) return b.status - a.status;
|
(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)
|
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
// 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 (
|
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>
|
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">📈</span>
|
<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 { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const SnowballView: Component = () => {
|
export const SnowballView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
return [...store.tasks]
|
|
||||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
.sort((a, b) => {
|
sourceTasks,
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
(a, b) => {
|
||||||
// Primary: Size ascending (smallest first)
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
const sizeA = a.size ?? 5;
|
// Primary: Size ascending (smallest first)
|
||||||
const sizeB = b.size ?? 5;
|
const sizeA = a.size ?? 5;
|
||||||
if (sizeA !== sizeB) return sizeA - sizeB;
|
const sizeB = b.size ?? 5;
|
||||||
// Secondary: Combined score descending (higher focus first)
|
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
// 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 (
|
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>
|
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">❄️</span>
|
<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 { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||||
|
import autoAnimate from "@formkit/auto-animate";
|
||||||
|
|
||||||
export const UrgencyView: Component = () => {
|
export const UrgencyView: Component = () => {
|
||||||
const sortedTasks = createMemo(() => {
|
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||||
return [...store.tasks]
|
|
||||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||||
.sort((a, b) => {
|
sourceTasks,
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
(a, b) => {
|
||||||
const uA = calculateUrgencyScore(a.dueDate);
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
const uB = calculateUrgencyScore(b.dueDate);
|
const uA = calculateUrgencyScore(a.dueDate);
|
||||||
if (uA !== uB) return uB - uA;
|
const uB = calculateUrgencyScore(b.dueDate);
|
||||||
// Tie-break with combined score (Priority + Tags)
|
if (uA !== uB) return uB - uA;
|
||||||
return getCombinedScore(b) - getCombinedScore(a);
|
// 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 (
|
return (
|
||||||
@@ -23,13 +32,17 @@ export const UrgencyView: Component = () => {
|
|||||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={sortedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => <TaskCard task={task} />}
|
{(task) => (
|
||||||
|
<div class="animate-task-enter">
|
||||||
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTasks().length === 0 && (
|
{displayedTasks().length === 0 && (
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
<span class="text-2xl">⏳</span>
|
<span class="text-2xl">⏳</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user