initial commit 3??

This commit is contained in:
2026-01-30 14:10:11 -06:00
parent 34bbe8e98f
commit f2a75c954f
42 changed files with 3935 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+27 -1
View File
@@ -1,2 +1,28 @@
# TasGrid
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)
+1213
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://shadcn-solid.com/schema.json",
"style": "default",
"baseColor": "slate",
"cssVariables": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tasgrid</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "tasgrid",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/extension-task-item": "^3.18.0",
"@tiptap/extension-task-list": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/suggestion": "^3.18.0",
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-solid": "^0.563.0",
"pocketbase": "^0.26.8",
"postcss": "^8.5.6",
"solid-js": "^1.9.10",
"solid-sonner": "^0.2.8",
"solid-tiptap": "^0.8.0",
"solid-transition-group": "^0.3.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tippy.js": "^6.3.7",
"vite-plugin-pwa": "^1.2.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.10"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+27
View File
@@ -0,0 +1,27 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.solid:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+41
View File
@@ -0,0 +1,41 @@
import { type Component, createSignal, Show, onMount, onCleanup } from 'solid-js';
import { Layout } from './components/Layout';
import { CriticalView } from './views/CriticalView';
import { UrgencyView } from './views/UrgencyView';
import { PriorityView } from './views/PriorityView';
import { MatrixView } from './views/MatrixView';
import { SettingsView } from './views/SettingsView';
import { AuthCallback } from './components/Auth';
import { pb } from './lib/pocketbase';
import { initStore } from './store';
const App: Component = () => {
// Basic routing state
const [currentView, setCurrentView] = createSignal("matrix");
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
onMount(() => {
const removeListener = pb.authStore.onChange((token) => {
setIsAuthenticated(!!token);
if (token) {
initStore();
}
}, true);
onCleanup(() => removeListener());
});
return (
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
<Layout currentView={currentView()} setView={setCurrentView}>
<Show when={currentView() === "critical"}><CriticalView /></Show>
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
<Show when={currentView() === "priority"}><PriorityView /></Show>
<Show when={currentView() === "matrix"}><MatrixView /></Show>
<Show when={currentView() === "settings"}><SettingsView /></Show>
</Layout>
</Show>
);
};
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+62
View File
@@ -0,0 +1,62 @@
import { createSignal } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { Button } from "@/components/ui/button";
import { toast } from "solid-sonner";
export const AuthCallback = (props: { onSuccess: () => void }) => {
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [loading, setLoading] = createSignal(false);
const handleLogin = async (e: Event) => {
e.preventDefault();
setLoading(true);
try {
await pb.collection("users").authWithPassword(email(), password());
toast.success("Welcome back!");
props.onSuccess();
} catch (err: any) {
console.error(err);
toast.error("Login failed. Please check your credentials.");
} finally {
setLoading(false);
}
};
return (
<div class="flex flex-col items-center justify-center min-h-screen bg-background p-4 animate-in fade-in duration-500">
<div class="w-full max-w-sm space-y-6">
<div class="text-center space-y-2">
<h1 class="text-2xl font-bold tracking-tight">Welcome to Tasgrid</h1>
<p class="text-sm text-muted-foreground">Sign in to sync your tasks across devices.</p>
</div>
<form onSubmit={handleLogin} class="space-y-4">
<div class="space-y-2">
<input
type="email"
placeholder="Email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<div class="space-y-2">
<input
type="password"
placeholder="Password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<Button type="submit" class="w-full" disabled={loading()}>
{loading() ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import { type Component, type JSX, createSignal, Show } from "solid-js";
import { Sidebar, BottomNav } from "./Navigation";
import { QuickEntry } from "./QuickEntry";
import { TaskDetail } from "./TaskDetail";
import { store, activeTaskId, setActiveTaskId } from "@/store";
import { PanelLeftOpen } from "lucide-solid";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
interface LayoutProps {
children: JSX.Element;
currentView: string;
setView: (v: string) => void;
}
export const Layout: Component<LayoutProps> = (props) => {
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
// Derived active task for the detail view
const activeTask = () => store.tasks.find(t => t.id === activeTaskId());
return (
<div class="flex h-screen w-full bg-background overflow-hidden relative font-sans antialiased text-foreground">
<Sidebar
currentView={props.currentView}
setView={props.setView}
isLocked={isSidebarLocked()}
setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()}
setIsPeeking={setIsSidebarPeeking}
/>
<div class={cn(
"flex-1 flex flex-col h-full transition-all duration-300 ease-in-out relative",
isSidebarLocked() ? "md:ml-48" : "ml-0"
)}>
{/* Expand Trigger Button (visible when not locked) */}
{!isSidebarLocked() && (
<div class="fixed top-4 left-4 z-[60] md:flex hidden">
<Button
variant="ghost"
size="icon"
onMouseEnter={() => setIsSidebarPeeking(true)}
onClick={() => setIsSidebarLocked(true)}
class="bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm"
>
<PanelLeftOpen size={18} />
</Button>
</div>
)}
<main class="flex-1 overflow-auto p-4 md:p-8 pt-4 pb-20 md:pb-8 scroll-smooth relative z-0">
{props.children}
</main>
{/* Mobile Bottom Nav */}
<BottomNav currentView={props.currentView} setView={props.setView} />
</div>
<QuickEntry />
<Show when={activeTask()}>
<TaskDetail
task={activeTask()!}
isOpen={!!activeTaskId()}
onClose={() => setActiveTaskId(null)}
/>
</Show>
</div>
);
};
+108
View File
@@ -0,0 +1,108 @@
import { type Component, For } from "solid-js";
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose } from "lucide-solid";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
interface NavItem {
icon: any;
label: string;
view: string;
}
const navItems: NavItem[] = [
{ icon: ListTodo, label: "Focus", view: "critical" },
{ icon: Clock, label: "Time", view: "urgency" },
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
{ icon: Settings, label: "Settings", view: "settings" },
];
export const BottomNav: Component<{ currentView: string; setView: (v: string) => void }> = (props) => {
return (
<nav class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center h-16 md:hidden z-50">
<For each={navItems}>
{(item) => (
<button
onClick={() => props.setView(item.view)}
class={cn(
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
props.currentView === item.view ? "text-primary" : "text-muted-foreground"
)}
>
<item.icon size={20} />
<span class="text-[10px] font-medium uppercase tracking-wider">{item.label}</span>
</button>
)}
</For>
</nav>
);
};
export const Sidebar: Component<{
currentView: string;
setView: (v: string) => void;
isLocked: boolean;
setIsLocked: (v: boolean) => void;
isPeeking: boolean;
setIsPeeking: (v: boolean) => void;
}> = (props) => {
return (
<aside
onMouseEnter={() => !props.isLocked && props.setIsPeeking(true)}
onMouseLeave={() => !props.isLocked && props.setIsPeeking(false)}
class={cn(
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]",
props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full",
!props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
>
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
<h1 class="text-xl font-bold tracking-tighter text-primary px-2">Tasgrid</h1>
{/* Close Button - Only visible when Locked (to collapse) or Peeking (manual close) */}
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => {
props.setIsLocked(false);
props.setIsPeeking(false);
}}
>
<PanelLeftClose size={16} />
</Button>
</div>
<nav class="flex-1 px-3 space-y-1 mt-2">
<For each={navItems.filter(i => i.view !== "settings")}>
{(item) => (
<button
onClick={() => props.setView(item.view)}
class={cn(
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
props.currentView === item.view
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">{item.label}</span>
</button>
)}
</For>
</nav>
<div class="p-3 border-t border-border mt-auto">
<button
onClick={() => props.setView("settings")}
class={cn(
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
props.currentView === "settings"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
<span class="font-medium">Settings</span>
</button>
</div>
</aside>
);
};
+251
View File
@@ -0,0 +1,251 @@
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "@/components/ui/button";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
export const QuickEntry: Component = () => {
const [isOpen, setIsOpen] = createSignal(false);
const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>("5");
const [urgency, setUrgency] = createSignal<string>("5");
// Reactive shorthand parsing
createEffect(() => {
const text = input();
const pMatch = text.match(/\/p(\d+)/);
if (pMatch) {
const val = Math.min(10, Math.max(1, parseInt(pMatch[1]))).toString();
setPriority(val);
}
const uMatch = text.match(/\/u(\d+)/);
if (uMatch) {
const val = Math.min(10, Math.max(1, parseInt(uMatch[1]))).toString();
if (val !== urgency()) {
onUrgencySimpleChange(val);
}
}
});
// Explicit date string for the input (YYYY-MM-DDTHH:mm)
const [dateString, setDateString] = createSignal<string>("");
let inputRef: HTMLInputElement | undefined;
createEffect(() => {
if (isOpen()) {
// Slight delay to ensure DOM is ready and animations don't interfere
setTimeout(() => inputRef?.focus(), 50);
}
});
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
if (isOpen()) {
inputRef?.focus();
} else {
setIsOpen(true);
}
}
if (e.key === "Escape") {
setIsOpen(false);
}
};
onMount(() => window.addEventListener("keydown", handleKeyDown));
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
// When urgency changes via dropdown, update the date
const onUrgencySimpleChange = (val: string) => {
setUrgency(val);
const level = parseInt(val);
if (!isNaN(level)) {
const newDateIso = calculateDateFromUrgency(level);
// Format for datetime-local input: YYYY-MM-DDTHH:mm
const dateObj = new Date(newDateIso);
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
setDateString(localIso);
}
};
// When date changes via input, update urgency level
const onDateInputChange = (e: Event) => {
const val = (e.target as HTMLInputElement).value;
setDateString(val);
if (val) {
const level = calculateUrgencyFromDate(new Date(val).toISOString());
setUrgency(level.toString());
}
};
const parseAndAdd = () => {
const text = input();
if (!text || !priority() || !urgency()) return;
let finalPriority = parseInt(priority());
let finalUrgency = parseInt(urgency());
// Parse shorthand overrides if present
const pMatch = text.match(/\/p(\d+)/);
if (pMatch) finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
// Check for urgency override
const uMatchNumeric = text.match(/\/u(\d+)/);
if (uMatchNumeric) {
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatchNumeric[1])));
}
let finalDueDate: Date | null = null;
if (dateString()) {
finalDueDate = new Date(dateString());
} else {
// If urgency is selected, calculate date
const u = parseInt(urgency());
if (!isNaN(u)) {
// Check if override happened
if (uMatchNumeric) {
const dateStr = calculateDateFromUrgency(finalUrgency);
finalDueDate = new Date(dateStr);
} else {
const dateStr = calculateDateFromUrgency(u);
finalDueDate = new Date(dateStr);
}
}
}
const title = text
.replace(/\/p\d+/, "")
.replace(/\/u\d+/, "")
.trim();
// Use the new signature: addTask(title, options)
addTask(title, {
priority: finalPriority,
dueDate: finalDueDate || undefined, // undefined will let store pick default if that was the intent, but here we probably want strictly calculated date
tags: []
});
setInput("");
setPriority("5");
setUrgency("5");
setDateString("");
setIsOpen(false);
};
return (
<>
<Button
size="icon"
onClick={() => setIsOpen(true)}
class="fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300"
>
<Plus size={28} class="group-hover:rotate-90 transition-transform duration-300" />
</Button>
{isOpen() && (
<div class="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div class="relative w-full max-w-xl bg-card border border-border rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div class="flex items-center px-4 py-3 border-b border-border">
<Search class="text-muted-foreground mr-3" size={20} />
<TextField class="flex-1">
<TextFieldInput
ref={inputRef}
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
class="border-none shadow-none focus-visible:ring-0 text-lg"
/>
</TextField>
</div>
<div class="flex flex-col md:flex-row items-stretch md:items-center p-4 gap-4 border-b border-border bg-muted/10">
<div class="flex-1 space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Priority</label>
<Select
value={priority()}
onChange={(val) => val && setPriority(val)}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
placeholder="Priority"
itemComponent={(props) => (
<SelectItem item={props.item}>
{props.item.rawValue}
</SelectItem>
)}
>
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3">
<div class="flex items-center gap-2">
<ArrowUpCircle size={14} class="text-primary shrink-0" />
<span class="text-sm font-medium">{priority()}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
<div class="flex-1 space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Urgency (Time)</label>
<Select
value={urgency()}
onChange={(val) => {
if (val) onUrgencySimpleChange(val);
}}
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
placeholder="Urgency"
itemComponent={(props) => (
<SelectItem item={props.item}>
{props.item.rawValue}
</SelectItem>
)}
>
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3">
<div class="flex items-center gap-2">
<Clock size={14} class="text-primary shrink-0" />
<span class="text-sm font-medium">{urgency()}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
<div class="flex-1 space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
<div class="relative">
<input
type="datetime-local"
value={dateString()}
onInput={onDateInputChange}
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
onKeyDown={(e) => {
if (e.key !== "Tab" && e.key !== "Escape") {
e.preventDefault();
}
}}
step="900"
class="w-full h-10 px-3 bg-background border-none rounded-md text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring z-[102] relative cursor-pointer"
/>
</div>
</div>
</div>
<div class="p-4 bg-muted/30 flex justify-between items-center">
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
<Command size={10} />
<span class="hidden sm:inline">K to focus Enter to Add</span>
<span class="sm:hidden">Enter to Add</span>
</div>
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
Create Task
</Button>
</div>
</div>
</div>
)}
</>
);
};
+164
View File
@@ -0,0 +1,164 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import {
Heading1, Heading2, Heading3,
List, ListTodo, Type,
Code, Quote
} from "lucide-solid";
import { cn } from "@/lib/utils";
export interface CommandItem {
title: string;
description: string;
icon: any;
command: (props: { editor: any; range: any }) => void;
}
export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => {
return [
{
title: "Text",
description: "Just start typing with plain text.",
icon: Type,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
icon: Heading1,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
icon: Heading2,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
icon: Heading3,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "Bullet List",
description: "Create a simple bulleted list.",
icon: List,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Task List",
description: "Track tasks with a checklist.",
icon: ListTodo,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Code Block",
description: "Capture a code snippet.",
icon: Code,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: "Blockquote",
description: "Capture a quote.",
icon: Quote,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
};
export const SlashMenu: Component<{
items: CommandItem[];
command: (item: CommandItem) => void;
editor: any;
ref: (handlers: { onKeyDown: (e: KeyboardEvent) => boolean }) => void;
}> = (props) => {
const [selectedIndex, setSelectedIndex] = createSignal(0);
// Auto-select the first item when the list changes (filtering)
createEffect(() => {
// We track props.items. When it changes, we reset.
props.items;
setSelectedIndex(0);
});
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command(item);
}
};
// Expose handlers to the parent via the ref prop
props.ref({
onKeyDown: (e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((prev) => (prev + 1) % props.items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex());
return true;
}
return false;
}
});
return (
<div
class="z-[200] w-64 bg-popover border border-border rounded-lg shadow-xl overflow-hidden p-1 bg-background pointer-events-auto"
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
<div class="max-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 overscroll-contain">
<For each={props.items}>
{(item, index) => (
<button
class={cn(
"flex items-center gap-3 w-full px-2 py-1.5 text-left rounded-md transition-colors outline-none",
index() === selectedIndex() ? "bg-accent text-accent-foreground" : "hover:bg-muted"
)}
onMouseDown={(e) => {
// Prevent editor focus loss and side-sheet closure
e.preventDefault();
e.stopPropagation();
selectItem(index());
}}
onMouseEnter={() => setSelectedIndex(index())}
>
<div class="flex items-center justify-center w-8 h-8 rounded border border-border bg-muted/50 text-muted-foreground shrink-0">
<item.icon size={16} />
</div>
<div class="flex flex-col min-w-0 overflow-hidden">
<span class="text-sm font-medium leading-none">{item.title}</span>
<span class="text-[11px] text-muted-foreground truncate mt-1">{item.description}</span>
</div>
</button>
)}
</For>
{props.items.length === 0 && (
<div class="p-2 text-xs text-muted-foreground text-center">No results found</div>
)}
</div>
</div>
);
};
+83
View File
@@ -0,0 +1,83 @@
import { type Component, createMemo } from "solid-js";
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId } from "@/store";
import { cn } from "@/lib/utils";
import { CheckCircle2, Circle, Clock, ArrowUpCircle } from "lucide-solid";
export const TaskCard: Component<{ task: Task }> = (props) => {
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
const urgencyColor = () => {
const u = urgencyLevel();
if (u >= 8) return "text-red-500";
if (u >= 6) return "text-orange-500";
if (u >= 4) return "text-blue-500";
return "text-muted-foreground";
};
const formattedDate = () => {
const date = new Date(props.task.dueDate);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
return (
<div
class={cn(
"group flex items-center p-4 bg-card border border-border rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer",
props.task.completed && "opacity-60"
)}
onClick={() => setActiveTaskId(props.task.id)}
>
<button
onClick={(e) => { e.stopPropagation(); toggleTask(props.task.id); }}
class="mr-4 text-muted-foreground hover:text-primary transition-colors shrink-0"
>
{props.task.completed ? (
<CheckCircle2 class="text-green-500" size={24} />
) : (
<Circle size={24} />
)}
</button>
<div class="flex-1 min-w-0">
<h3 class={cn(
"text-base font-semibold truncate transition-all",
props.task.completed && "line-through"
)}>
{props.task.title}
</h3>
<div class="flex items-center space-x-4 mt-1">
{/* Priority */}
<div class="flex items-center gap-1.5 min-w-[32px]">
<ArrowUpCircle size={12} class="text-primary opacity-60" />
<span>P{props.task.priority}</span>
</div>
{/* Due Date + Urgency Slide-out */}
<div class="relative group/date flex items-center h-6 cursor-help">
{/* Due Date - Static Layer on Top */}
<div class="flex items-center space-x-1.5 text-[10px] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
<Clock size={12} class="text-primary/60" />
<span>{formattedDate()}</span>
</div>
{/* Urgency Number - Slides out from behind with fade */}
<div class={cn(
"absolute left-4 top-0 bottom-0 flex items-center transition-all duration-300 ease-out z-10 opacity-0 group-hover/date:opacity-100 group-hover/date:left-full whitespace-nowrap",
urgencyColor()
)}>
<span class="text-[10px] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
Urgency {urgencyLevel()}
</span>
</div>
</div>
</div>
</div>
<div class="ml-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button class="p-2 hover:bg-muted rounded-full transition-colors">
<div class="w-1.5 h-1.5 bg-muted-foreground rounded-full" />
</button>
</div>
</div>
);
};
+213
View File
@@ -0,0 +1,213 @@
import { type Component, createEffect, createSignal } from "solid-js";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { type Task, removeTask, restoreTask, updateTask } from "@/store";
import { TaskEditor } from "@/components/TaskEditor";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2 } from "lucide-solid";
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
import { toast } from "solid-sonner";
interface TaskDetailProps {
task: Task;
isOpen: boolean;
onClose: () => void;
}
export const TaskDetail: Component<TaskDetailProps> = (props) => {
// Local state for immediate feedback, synced with props
const [title, setTitle] = createSignal(props.task.title);
const [editorInstance, setEditorInstance] = createSignal<any>(null);
// Sync input string for datetime-local
const [dateString, setDateString] = createSignal<string>("");
createEffect(() => {
setTitle(props.task.title);
// Format for datetime-local: yyyy-MM-ddThh:mm (local time)
const dateObj = new Date(props.task.dueDate);
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
setDateString(localIso);
});
let debounceTimer: number | undefined;
const updateTaskContent = (html: string) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateTask(props.task.id, { content: html });
}, 1000);
};
const updateTitle = (val: string) => {
setTitle(val);
// Debouncing could be added here, but direct update is fine for now if PB handles it well
updateTask(props.task.id, { title: val });
};
const updatePriority = (val: string) => {
const p = parseInt(val);
if (!isNaN(p)) {
updateTask(props.task.id, { priority: p });
}
};
const updateUrgency = (val: string) => {
const u = parseInt(val);
if (!isNaN(u)) {
const newDate = calculateDateFromUrgency(u);
updateTask(props.task.id, { dueDate: newDate });
}
};
const onDateInputChange = (e: Event) => {
const val = (e.currentTarget as HTMLInputElement).value;
if (val) {
const iso = new Date(val).toISOString();
updateTask(props.task.id, { dueDate: iso });
}
};
// Calculate current urgency level for display
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
return (
<Sheet open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
<SheetContent class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl">
{/* Header Area */}
<div class="px-6 pt-6 pb-2 shrink-0">
<textarea
value={title()}
onInput={(e) => {
updateTitle(e.currentTarget.value);
// Auto-resize
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
ref={(el) => {
setTimeout(() => {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, 0);
}}
rows={1}
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight"
placeholder="Untitled Task"
/>
{/* Properties Bar */}
<div class="flex flex-row items-center gap-1.5 sm:gap-4 mt-2 text-sm overflow-x-auto no-scrollbar flex-nowrap">
{/* Priority */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
<Select
value={props.task.priority.toString()}
onChange={(val) => val && updatePriority(val)}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
placeholder="Priority"
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
<ArrowUpCircle size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
<span class="text-xs sm:text-sm">{props.task.priority}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Urgency */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Time</span>
<Select
value={currentUrgency()}
onChange={(val) => val && updateUrgency(val)}
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
placeholder="Urgency"
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
<Clock size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
<span class="text-xs sm:text-sm">{currentUrgency()}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Commands Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[10px] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().insertContent("/").run();
}
}}
>
<Type size={14} class="opacity-70" />
<span class="hidden sm:inline">Commands</span>
</Button>
</div>
{/* Date - Editable */}
<div class="flex items-center gap-1 text-muted-foreground group relative shrink-0 min-w-0 flex-1 sm:flex-initial">
<Calendar size={14} class="group-hover:text-primary transition-colors shrink-0" />
<input
type="datetime-local"
value={dateString()}
onInput={onDateInputChange}
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
onKeyDown={(e) => {
if (e.key !== "Tab" && e.key !== "Escape") {
e.preventDefault();
}
}}
step="900"
class="bg-transparent border-none p-0 text-[11px] sm:text-xs font-medium focus:ring-0 focus:outline-none cursor-pointer hover:text-foreground appearance-none min-w-[125px] w-full"
title="Change Due Date"
/>
</div>
{/* Delete Button */}
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button
variant="ghost"
size="sm"
class="h-8 w-8 px-0 hover:bg-red-500/10 hover:text-red-500 text-muted-foreground transition-colors"
onClick={() => {
const taskId = props.task.id;
removeTask(taskId);
props.onClose();
toast.info("Task moved to trash", {
action: {
label: "Undo",
onClick: () => restoreTask(taskId)
}
});
}}
>
<Trash2 size={16} />
</Button>
</div>
</div>
<div class="h-px w-full bg-border mt-4" />
</div>
{/* Editor Content */}
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20">
<TaskEditor
content={props.task.content}
onUpdate={updateTaskContent}
onEditorReady={setEditorInstance}
/>
</div>
</SheetContent>
</Sheet>
);
};
+99
View File
@@ -0,0 +1,99 @@
import { type Component, createEffect, untrack } from "solid-js";
import { createTiptapEditor } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import { cn } from "@/lib/utils";
import { SlashCommands } from "@/lib/slash-command";
import { suggestion } from "@/lib/slash-renderer";
interface TaskEditorProps {
content?: string;
onUpdate: (html: string) => void;
onEditorReady?: (editor: any) => void;
editable?: boolean;
class?: string;
}
export const TaskEditor: Component<TaskEditorProps> = (props) => {
let editorRef: HTMLDivElement | undefined;
const editor = createTiptapEditor(() => ({
element: editorRef!,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder: "Type '/' for commands or start writing...",
emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0",
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2 items-start my-1",
},
}),
SlashCommands.configure({
suggestion,
}),
],
// Use untrack so the editor doesn't re-initialize when props.content changes
content: untrack(() => props.content) || "",
editable: props.editable ?? true,
onUpdate: ({ editor }) => {
props.onUpdate(editor.getHTML());
},
editorProps: {
attributes: {
class: cn(
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
"prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
"prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4",
"prose-img:rounded-lg prose-img:border prose-img:border-border",
// Custom Task List Styling
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
"[&_li[data-type='taskItem']>label]:mr-2 [&_li[data-type='taskItem']>label]:mt-0.5 [&_li[data-type='taskItem']>label]:select-none",
"[&_li[data-type='taskItem']>div]:flex-1",
props.class
),
},
},
}));
// Expose editor to parent
createEffect(() => {
const e = editor();
if (e && props.onEditorReady) {
props.onEditorReady(e);
}
});
// Sync content updates from outside if needed (but only if NOT focused to avoid jumps)
createEffect(() => {
const content = props.content;
const e = editor();
if (e && content !== undefined && !e.isFocused && content !== e.getHTML()) {
e.commands.setContent(content, { emitUpdate: false });
}
});
return (
<div
ref={editorRef}
class="w-full h-full cursor-text"
onClick={() => editor()?.chain().focus().run()}
/>
);
};
+48
View File
@@ -0,0 +1,48 @@
import { createSignal, createContext, useContext, createEffect, type ParentComponent } from "solid-js";
type Theme = "dark" | "light" | "system";
interface ThemeProviderState {
theme: () => Theme;
setTheme: (theme: Theme) => void;
}
const ThemeProviderContext = createContext<ThemeProviderState>();
export const ThemeProvider: ParentComponent = (props) => {
const [theme, setTheme] = createSignal<Theme>("system");
// Load from local storage on mount
const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null;
if (storedTheme) {
setTheme(storedTheme);
}
createEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
const currentTheme = theme();
if (currentTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme);
} else {
root.classList.add(currentTheme);
}
localStorage.setItem("tasgrid-theme", currentTheme);
});
return (
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
{props.children}
</ThemeProviderContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
+21
View File
@@ -0,0 +1,21 @@
import { type Component } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-solid";
export const ThemeToggle: Component = () => {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme() === "dark" ? "light" : "dark")}
class="w-9 h-9 px-0"
>
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</Button>
);
};
+54
View File
@@ -0,0 +1,54 @@
import { splitProps, type JSX } from "solid-js"
import { Button as ButtonPrimitive } from "@kobalte/core/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
export interface ButtonProps extends VariantProps<typeof buttonVariants> {
class?: string
onClick?: (e: MouseEvent) => void
onMouseEnter?: (e: MouseEvent) => void
onMouseLeave?: (e: MouseEvent) => void
children?: JSX.Element
size?: "default" | "sm" | "lg" | "icon"
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
type?: "button" | "submit" | "reset"
disabled?: boolean
}
const Button = (props: ButtonProps) => {
const [local, others] = splitProps(props, ["variant", "size", "class"])
return (
<ButtonPrimitive
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
{...others}
/>
)
}
export { Button, buttonVariants }
+23
View File
@@ -0,0 +1,23 @@
import { splitProps } from "solid-js"
import { Popover as PopoverPrimitive } from "@kobalte/core/popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = (props: import("solid-js").ComponentProps<typeof PopoverPrimitive.Content>) => {
const [local, others] = splitProps(props, ["class"])
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
class={cn(
"z-[110] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
local.class
)}
{...others}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }
+62
View File
@@ -0,0 +1,62 @@
import { splitProps, type ComponentProps } from "solid-js"
import { Select as SelectPrimitive } from "@kobalte/core/select"
import { cn } from "@/lib/utils"
import { Check, ChevronDown } from "lucide-solid"
const Select = SelectPrimitive
const SelectValue = SelectPrimitive.Value
const SelectTrigger = (props: ComponentProps<typeof SelectPrimitive.Trigger>) => {
const [local, others] = splitProps(props, ["class", "children"])
return (
<SelectPrimitive.Trigger
class={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
local.class
)}
{...others}
>
{local.children}
<SelectPrimitive.Icon as={ChevronDown} class="h-4 w-4 opacity-50 transition-transform duration-200" />
</SelectPrimitive.Trigger>
)
}
const SelectContent = (props: ComponentProps<typeof SelectPrimitive.Content>) => {
const [local, others] = splitProps(props, ["class"])
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
class={cn(
"relative z-[200] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
local.class
)}
{...others}
>
<SelectPrimitive.Listbox class="p-1" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
const SelectItem = (props: ComponentProps<typeof SelectPrimitive.Item>) => {
const [local, others] = splitProps(props, ["class", "children"])
return (
<SelectPrimitive.Item
class={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
local.class
)}
{...others}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemLabel>{local.children}</SelectPrimitive.ItemLabel>
</SelectPrimitive.Item>
)
}
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem }
+98
View File
@@ -0,0 +1,98 @@
import { type Component, splitProps, type ComponentProps } from "solid-js";
import { Dialog as SheetPrimitive } from "@kobalte/core/dialog";
import { cn } from "@/lib/utils";
import { X } from "lucide-solid";
const Sheet = SheetPrimitive;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.CloseButton;
const SheetOverlay: Component<ComponentProps<typeof SheetPrimitive.Overlay>> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<SheetPrimitive.Overlay
class={cn(
"fixed inset-0 z-[150] bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
local.class
)}
{...others}
/>
);
};
const SheetContent: Component<ComponentProps<typeof SheetPrimitive.Content>> = (props) => {
const [local, others] = splitProps(props, ["class", "children"]);
return (
<SheetPrimitive.Portal>
<SheetOverlay />
<SheetPrimitive.Content
class={cn(
"fixed z-[150] gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-xl",
local.class
)}
{...others}
>
{/* Close Button */}
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.CloseButton>
{local.children}
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
);
};
const SheetHeader: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<div
class={cn("flex flex-col space-y-2 text-center sm:text-left", local.class)}
{...others}
/>
);
};
const SheetFooter: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<div
class={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
local.class
)}
{...others}
/>
);
};
const SheetTitle: Component<ComponentProps<typeof SheetPrimitive.Title>> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<SheetPrimitive.Title
class={cn("text-lg font-semibold text-foreground", local.class)}
{...others}
/>
);
};
const SheetDescription: Component<ComponentProps<typeof SheetPrimitive.Description>> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<SheetPrimitive.Description
class={cn("text-sm text-muted-foreground", local.class)}
{...others}
/>
);
};
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+30
View File
@@ -0,0 +1,30 @@
import { Toaster as Sonner } from "solid-sonner"
import { useTheme } from "../ThemeProvider"
type ToasterProps = Parameters<typeof Sonner>[0]
const Toaster = (props: ToasterProps) => {
const { theme } = useTheme()
return (
<Sonner
theme={theme() as ToasterProps["theme"]}
class="toaster group"
position="top-center"
toastOptions={{
classes: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
+60
View File
@@ -0,0 +1,60 @@
import { splitProps, type ComponentProps } from "solid-js"
import { TextField as TextFieldPrimitive } from "@kobalte/core/text-field"
import { cn } from "@/lib/utils"
const TextField = TextFieldPrimitive
const TextFieldInput = (props: ComponentProps<"input">) => {
const [local, others] = splitProps(props, ["class", "type"])
return (
<TextFieldPrimitive.Input
type={local.type}
class={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
local.class
)}
{...(others as any)}
/>
)
}
const TextFieldLabel = (props: any) => {
const [local, others] = splitProps(props, ["class"])
return (
<TextFieldPrimitive.Label
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
local.class
)}
{...others}
/>
)
}
const TextFieldDescription = (props: any) => {
const [local, others] = splitProps(props, ["class"])
return (
<TextFieldPrimitive.Description
class={cn("text-[0.8rem] text-muted-foreground", local.class)}
{...others}
/>
)
}
const TextFieldErrorMessage = (props: any) => {
const [local, others] = splitProps(props, ["class"])
return (
<TextFieldPrimitive.ErrorMessage
class={cn("text-[0.8rem] font-medium text-destructive", local.class)}
{...others}
/>
)
}
export {
TextField,
TextFieldInput,
TextFieldLabel,
TextFieldDescription,
TextFieldErrorMessage
}
+125
View File
@@ -0,0 +1,125 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
--primary: hsl(222.2 47.4% 11.2%);
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(210 40% 96.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(210 40% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.fade-enter-active,
.fade-exit-active {
transition: opacity 0.2s ease;
}
.fade-enter,
.fade-exit-to {
opacity: 0;
}
+16
View File
@@ -0,0 +1,16 @@
import { render } from 'solid-js/web'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
const root = document.getElementById('root')
render(() => {
return (
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
);
}, root!)
+5
View File
@@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const TASGRID_COLLECTION = 'TasGrid';
+26
View File
@@ -0,0 +1,26 @@
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
export const SlashCommands = Extension.create({
name: "slashCommands",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
+81
View File
@@ -0,0 +1,81 @@
import { render } from "solid-js/web";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { SlashMenu, getSuggestionItems } from "../components/SlashMenu";
export const suggestion = {
items: getSuggestionItems,
render: () => {
let component: (() => void) | undefined;
let popup: TippyInstance | undefined;
let container: HTMLDivElement | undefined;
let componentHandlers: { onKeyDown: (e: KeyboardEvent) => boolean } | undefined;
return {
onStart: (props: any) => {
container = document.createElement("div");
component = render(
() => <SlashMenu
items={props.items}
command={props.command}
editor={props.editor}
ref={(h) => componentHandlers = h}
/>,
container
);
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => props.editor.options.element || document.body,
content: container,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})[0];
},
onUpdate(props: any) {
// Re-rendering to update items
if (component) component();
component = render(
() => <SlashMenu
items={props.items}
command={props.command}
editor={props.editor}
ref={(h) => componentHandlers = h}
/>,
container!
);
if (props.clientRect) {
popup?.setProps({
getReferenceClientRect: props.clientRect,
});
}
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
popup?.hide();
return true;
}
return componentHandlers?.onKeyDown(props.event) ?? false;
},
onExit() {
if (popup && !popup.state.isDestroyed) {
popup.destroy();
}
popup = undefined;
if (component) {
component();
component = undefined;
}
},
};
},
};
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+332
View File
@@ -0,0 +1,332 @@
// ... (imports)
import { createStore } from "solid-js/store";
import { createSignal } from "solid-js";
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
export const [now, setNow] = createSignal(Date.now());
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export type UrgencyLevel = number;
export interface Task {
id: string;
title: string;
startDate: string;
dueDate: string;
priority: number; // 1-10
completed: boolean;
tags: string[];
content?: string; // HTML content from Tiptap
deletedAt?: number; // Timestamp of soft delete
}
interface TaskStore {
tasks: Task[];
pWeight: number;
uWeight: number;
matrixScaleDays: number;
prefId?: string; // ID of the user_preferences record
}
// Initial state
export const [store, setStore] = createStore<TaskStore>({
tasks: [],
pWeight: 1.0,
uWeight: 1.0,
matrixScaleDays: 30, // Default requested by user
});
// -- Calibration --
export const calculateUrgencyScore = (dueDateStr: string): number => {
const due = new Date(dueDateStr).getTime();
const diffHours = (due - now()) / (1000 * 60 * 60);
// Geometric formulation
// Base is derived so that urgency 1 ~ matrixScaleDays
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
// Reverse formula: Level = 10 - (ln(hours/24) / ln(base))
// If hours <= 24, log(<=1) is negative or 0, so Level >= 10.
if (diffHours <= 24) return 10;
const level = 10 - (Math.log(diffHours / 24) / Math.log(base));
return Math.max(1, Math.min(10, level));
};
export const calculateDateFromUrgency = (level: number): string => {
// Inverse of above
if (level >= 10) {
return new Date(now() + 24 * 60 * 60 * 1000).toISOString();
}
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
const hours = 24 * Math.pow(base, 10 - level);
return new Date(now() + hours * 60 * 60 * 1000).toISOString();
};
export const calculateUrgencyFromDate = (dateStr: string): number => {
return Math.round(calculateUrgencyScore(dateStr));
};
export const getCombinedScore = (task: Task): number => {
const urgencyScore = calculateUrgencyScore(task.dueDate);
return (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
};
// -- Persistence & Sync --
let heartbeatInterval: number | undefined;
export const initStore = async () => {
if (!pb.authStore.isValid) return;
// Heartbeat - Ensure only one interval is running
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
try {
// 1. Fetch Preferences from User Profile
try {
const userId = pb.authStore.model?.id;
if (userId) {
// Fetch fresh user record
const user = await pb.collection('users').getOne(userId, { requestKey: null });
// Field: Taskgrid_pref (JSON)
const prefs = user.Taskgrid_pref || {};
setStore({
pWeight: prefs.pWeight || 1.0,
uWeight: prefs.uWeight || 1.0,
matrixScaleDays: prefs.matrixScaleDays || 30,
prefId: userId // The ID to update is the User ID
});
}
} catch (prefErr) {
// Non-blocking error for preferences
console.warn("Failed to load preferences from user profile:", prefErr);
}
// 2. Fetch Tasks
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
sort: '-created',
});
// Map PB records to Task interface
const loadedTasks: Task[] = records.map(r => ({
id: r.id,
title: r.title,
startDate: r.startDate,
dueDate: r.dueDate,
priority: r.priority,
completed: r.completed,
tags: r.tags || [],
content: r.content,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
}));
setStore("tasks", loadedTasks);
} catch (err) {
console.error("Failed to load data:", err);
toast.error("Failed to sync with server.");
}
};
// -- Actions --
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[] }) => {
if (!pb.authStore.isValid) return;
// Default to ~24h from now if no date provided
let dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
if (options?.dueDate) {
dueDate = options.dueDate instanceof Date ? options.dueDate.toISOString() : options.dueDate;
}
const startDate = new Date().toISOString();
const priority = options?.priority ?? 5;
const tags = options?.tags ?? ["work"];
const tempId = "temp-" + Date.now();
const newTask: Task = {
id: tempId,
title,
startDate,
dueDate,
priority,
completed: false,
tags
};
// Optimistic UI update
setStore("tasks", (t) => [newTask, ...t]);
try {
const record = await pb.collection(TASGRID_COLLECTION).create({
user: pb.authStore.model?.id,
title,
startDate,
dueDate,
priority,
completed: false,
tags
});
// Replace temp task with real record
setStore("tasks", (t) => t.map(task => task.id === tempId ? { ...task, id: record.id } : task));
} catch (err) {
console.error("create failed", err);
toast.error("Failed to save task.");
// Rollback?
setStore("tasks", (t) => t.filter(task => task.id !== tempId));
}
};
export const updateTask = async (id: string, updates: Partial<Task>) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("tasks", (t) => t.id === id, updates);
try {
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
// Check for specific fields being updated
const pbUpdates: any = { ...updates };
if (updates.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 (updates.deletedAt) {
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null; // restore
}
}
// Disable auto-cancellation for updates to prevent aborted errors during rapid typing
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
} catch (err) {
console.error("update failed", err);
toast.error("Failed to update task.");
}
};
// Wrapper for updateTask to fit setStore style usage in components if needed,
// but for now we'll just intercept the setStore calls or provide specific actions.
// The components currently use `setStore("tasks", ...)` directly in some places.
// We need to verify if we should refactor those components to use actions or use a store effect.
// Ideally, we use a createEffect to watch the store, but that can trigger on every load.
// Better to export specific actions.
// -- Legacy Adapters --
// Many components use setStore directly. To support them without full refactor,
// we can watch specific fields or just migrate them to use actions.
// Given strict instructions, let's look at what we've seen.
// TaskDetail uses `removeTask`.
// QuickEntry uses `addTask`.
// TaskDetail uses `setStore` for title/priority updates. THIS IS A PROBLEM for sync.
// We should provide a way to sync these changes.
// Let's adding a store effect to sync changes?
// It's risky. Let's provide an explicit hook or functions and fix the components.
// OR, we overwrite the `setStore` export? No, that's internal.
// Let's updated `setStore` usage in this file to be purely local,
// and add a `syncTask` helper that components should call?
// Or better: Replace the `setStore` export with a wrapped version? Hard with type inference.
//
// Best approach for now:
// Keep `setStore` for local UI state.
// Add a `createEffect` that watches `store.tasks`?
// No, deep watching is expensive.
//
// Let's sticking to: Components SHOULD call actions.
// But TaskDetail calls `setStore`.
// I will start by modifying `setStore` usages in TaskDetail/etc in a subsequent step if needed.
// FOR NOW: I will export a `updateTaskField` helper and update components to use it.
// Actually, `TaskDetail` was edited to use `setStore` in Step 1520.
// line 33: setStore("tasks", (t) => t.id === props.task.id, "content", html);
// line 38, 44, 52, 60... all use setStore.
// I MUST refactor TaskDetail to use an action.
export const updateTaskField = (id: string, field: keyof Task, value: any) => {
setStore("tasks", (t) => t.id === id, field, value);
// Debounce this?
// For now, fire and forget update
updateTask(id, { [field]: value });
};
// -- Matrix Scale --
export const setMatrixScaleDays = async (days: number) => {
setStore("matrixScaleDays", days);
// Sync to users collection
if (store.prefId) {
try {
const prefs = {
pWeight: store.pWeight,
uWeight: store.uWeight,
matrixScaleDays: days
};
await pb.collection('users').update(store.prefId, {
Taskgrid_pref: prefs
});
} catch (err) {
console.error("Failed to update preferences", err);
}
}
};
export const toggleTask = (id: string) => {
const task = store.tasks.find(t => t.id === id);
if (task) {
updateTask(id, { completed: !task.completed });
}
};
export const removeTask = (id: string) => {
// Soft delete
const nowTs = Date.now();
updateTask(id, { deletedAt: nowTs });
};
export const restoreTask = (id: string) => {
// This requires passing undefined or null to updateTask
// createStore handles undefined ok.
setStore("tasks", (t) => t.id === id, "deletedAt", undefined);
// For PB, we need to send null explicitly for date clear?
// updateTask handles this check.
updateTask(id, { deletedAt: undefined });
};
export const deleteTaskPermanently = async (id: string) => {
if (!pb.authStore.isValid) return;
// Optimistic
setStore("tasks", (t) => t.filter((t) => t.id !== id));
try {
await pb.collection(TASGRID_COLLECTION).delete(id);
} catch (err) {
console.error("delete failed", err);
toast.error("Failed to delete task.");
// Reload tasks?
}
};
// Legacy cleanup - We don't need local storage persistence setup anymore
export const setupPersistence = () => {
// Moved logic to initStore which is called on auth.
// Keeping function signature to avoid breaking index.js calls immediately,
// but it will be empty or a no-op if called before auth.
// Actually, index.tsx calls this. We can leave it empty or have it check auth.
initStore();
};
+36
View File
@@ -0,0 +1,36 @@
import { type Component, For, createMemo } from "solid-js";
import { store, getCombinedScore } from "@/store";
import { TaskCard } from "@/components/TaskCard";
export const CriticalView: Component = () => {
const sortedTasks = createMemo(() => {
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return getCombinedScore(b) - getCombinedScore(a);
});
});
return (
<div class="space-y-6">
<header>
<h2 class="text-3xl font-bold tracking-tight">Focus</h2>
<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} />}
</For>
</div>
{sortedTasks().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>
</div>
<p class="text-muted-foreground">Clear horizon. No critical tasks pending.</p>
</div>
)}
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
import { type Component, For, createMemo } from "solid-js";
import { store, calculateUrgencyScore, setActiveTaskId, setStore, getCombinedScore } from "@/store";
import { cn } from "@/lib/utils";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
export const MatrixView: Component = () => {
const activeTasks = createMemo(() => store.tasks.filter(t => !t.completed && !t.deletedAt));
const scaleOptions = ["1", "7", "30", "60", "90"];
return (
<div class="h-[calc(100vh-12rem)] md:h-[calc(100vh-8rem)] flex flex-col">
<header class="mb-6">
<h2 class="text-3xl font-bold tracking-tight">Strategy Matrix</h2>
<p class="text-muted-foreground mt-1 text-lg">Urgency vs Priority mapping.</p>
</header>
<div class="flex-1 relative border-2 border-dashed border-muted rounded-3xl overflow-hidden bg-muted/20">
{/* Axis Labels */}
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-2 z-30">
<span> Soon</span>
<div class="flex items-center gap-1 bg-background/50 backdrop-blur-sm px-2 py-0.5 rounded-full border border-border/50 group hover:bg-background/80 transition-colors cursor-pointer">
<span class="text-[9px]">Time:</span>
<Select
value={store.matrixScaleDays.toString()}
onChange={(val) => val && setStore("matrixScaleDays", parseInt(val))}
options={scaleOptions}
placeholder="Scale"
itemComponent={(props) => (
<SelectItem item={props.item}>
{props.item.rawValue} {props.item.rawValue === "1" ? "day" : "days"}
</SelectItem>
)}
>
<SelectTrigger class="h-auto p-0 min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[10px] font-black text-foreground">
<span>{store.matrixScaleDays} {store.matrixScaleDays === 1 ? 'day' : 'days'}</span>
</SelectTrigger>
<SelectContent class="z-[150]" />
</Select>
</div>
<span>Later </span>
</div>
<div class="absolute left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 pointer-events-none">
Low (Priority) High
</div>
{/* Quadrant Lines */}
<div class="absolute inset-0 flex pointer-events-none">
<div class="flex-1 border-r border-muted/30" />
<div class="flex-1" />
</div>
<div class="absolute inset-0 flex flex-col pointer-events-none">
<div class="flex-1 border-b border-muted/30" />
<div class="flex-1" />
</div>
{/* Quadrant Labels */}
<div class="absolute top-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DO FIRST</div>
<div class="absolute top-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">SCHEDULE</div>
<div class="absolute bottom-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DELEGATE</div>
<div class="absolute bottom-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">ELIMINATE</div>
{/* Task Pips */}
<For each={activeTasks()}>
{(task) => {
const getPosition = () => {
// Continuous geometric urgency mapping
const now = new Date().getTime();
const due = new Date(task.dueDate).getTime();
const diffHours = Math.max(0.1, (due - now) / (1000 * 60 * 60));
// Level formula: H = 24 * base^(10-v) => v = 10 - logBase(H/24)
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
const continuousLevel = 10 - (Math.log(diffHours / 24) / Math.log(base));
const clampedLevel = Math.min(10, Math.max(1, continuousLevel));
// Map 1-10 level to 10-90% X coordinate (10 on left, 1 on right)
const x = 100 - (((clampedLevel - 1) / 9) * 80 + 10);
// Y-axis: Priority (1 to 10)
// 10 = 10%, 1 = 90%
const y = 100 - (((task.priority - 1) / 9) * 80 + 10);
return { x, y };
};
const urgencyScore = calculateUrgencyScore(task.dueDate);
const combinedScore = getCombinedScore(task);
// Map 1-10 to HSL Hue (0 = Red, 240 = Blue)
// Using a slightly limited range for better aesthetics (0 to 220)
const hue = ((10 - combinedScore) / 9) * 220;
const dotColor = `hsl(${hue}, 85%, 55%)`;
return (
<div
class="absolute group cursor-pointer z-10"
onClick={() => setActiveTaskId(task.id)}
style={{
left: `${getPosition().x}%`,
top: `${getPosition().y}%`,
transform: 'translate(-50%, -50%)'
}}
>
<div
class="w-4 h-4 rounded-full border-2 border-background shadow-lg transition-all duration-300 group-hover:scale-150 group-hover:ring-4"
style={{
"background-color": dotColor,
"--tw-ring-color": `${dotColor}40` // 25% opacity for ring
}}
/>
{/* Tooltip */}
<div class={cn(
"absolute left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all pointer-events-none z-20 w-48",
getPosition().y < 25 ? "top-full mt-3" : "bottom-full mb-3"
)}>
<div class={cn(
"w-2 h-2 bg-popover border-l border-t border-border rotate-45 mx-auto",
getPosition().y < 25 ? "-mb-1 order-first" : "-mt-1 order-last"
)} />
<div class="bg-popover text-popover-foreground border border-border rounded-xl p-3 shadow-2xl scale-95 group-hover:scale-100 transition-transform">
<p class="text-xs font-bold leading-tight">{task.title}</p>
<div class="flex items-center justify-between mt-2 pt-2 border-t border-border">
<span class="text-[9px] text-muted-foreground">U: {urgencyScore}</span>
<span class="text-[9px] text-muted-foreground">P: {task.priority}</span>
</div>
</div>
<div class={cn(
"w-2 h-2 bg-popover border-r border-b border-border rotate-45 mx-auto",
getPosition().y < 25 ? "hidden" : "-mt-1"
)} />
</div>
</div>
);
}}
</For>
</div>
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { type Component, For, createMemo } from "solid-js";
import { store, calculateUrgencyScore } from "@/store";
import { TaskCard } from "@/components/TaskCard";
export const PriorityView: Component = () => {
const sortedTasks = createMemo(() => {
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
if (a.priority !== b.priority) return b.priority - a.priority;
const uA = calculateUrgencyScore(a.dueDate);
const uB = calculateUrgencyScore(b.dueDate);
return uB - uA;
});
});
return (
<div class="space-y-6">
<header>
<h2 class="text-3xl font-bold tracking-tight">Priority</h2>
<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} />}
</For>
</div>
{sortedTasks().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>
</div>
<p class="text-muted-foreground">Focus sharp. No high-priority tasks yet.</p>
</div>
)}
</div>
);
};
+121
View File
@@ -0,0 +1,121 @@
import { type Component, For } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays } from "@/store";
import { Trash2, Undo2, ArrowLeftRight } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { toast } from "solid-sonner";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { pb } from "@/lib/pocketbase";
export const SettingsView: Component = () => {
return (
<div class="px-6 py-10 max-w-2xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-2 duration-500">
<header class="space-y-1 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
<p class="text-muted-foreground">{pb.authStore.model?.email}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { pb.authStore.clear(); location.reload(); }}>Sign Out</Button>
</header>
<div class="grid gap-6">
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<h3 class="text-base font-semibold">Appearance</h3>
<p class="text-sm text-muted-foreground">Select your preferred color scheme.</p>
</div>
<ThemeToggle />
</div>
</section>
{/* Matrix Configuration */}
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<ArrowLeftRight size={16} />
Matrix Horizon
</h3>
<p class="text-sm text-muted-foreground">Adjust the time scale for your strategy grid.</p>
</div>
<div class="w-[120px]">
<Select
value={store.matrixScaleDays.toString()}
onChange={(val) => val && setMatrixScaleDays(parseInt(val))}
options={["7", "14", "30", "60", "90"]}
placeholder="Select days"
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue} days</SelectItem>}
>
<SelectTrigger class="h-9">
<span class="text-sm font-medium">{store.matrixScaleDays} days</span>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</section>
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Trash2 size={16} />
Trash
</h3>
<p class="text-sm text-muted-foreground">Items are permanently deleted after 7 days.</p>
</div>
<div class="space-y-2">
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
Trash is empty.
</div>
}>
{(task) => {
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 group hover:bg-muted/50 transition-colors">
<div class="min-w-0 flex-1 mr-4">
<p class="font-medium truncate">{task.title || "Untitled"}</p>
<p class="text-[10px] text-muted-foreground">Expires in {Math.max(0, daysLeft)} days</p>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-8 px-2 text-xs hover:bg-green-500/10 hover:text-green-600"
onClick={() => {
restoreTask(task.id);
toast.success("Task restored");
}}
>
<Undo2 size={14} class="mr-1" />
Recover
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600"
onClick={() => {
// For permanent deletion, still using confirm for safety but ensuring toast follows
if (confirm("Permanently delete this task? This cannot be undone.")) {
deleteTaskPermanently(task.id);
toast.error("Task permanently deleted", {
duration: 3000
});
}
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}}
</For>
</div>
</section>
</div>
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { type Component, For, createMemo } from "solid-js";
import { store, calculateUrgencyScore } from "@/store";
import { TaskCard } from "@/components/TaskCard";
export const UrgencyView: Component = () => {
const sortedTasks = createMemo(() => {
return [...store.tasks].filter(t => !t.deletedAt).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;
return b.priority - a.priority;
});
});
return (
<div class="space-y-6">
<header>
<h2 class="text-3xl font-bold tracking-tight">Time</h2>
<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} />}
</For>
</div>
{sortedTasks().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>
</div>
<p class="text-muted-foreground">All clear. No time-sensitive tasks.</p>
</div>
)}
</div>
);
};
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { VitePWA } from 'vite-plugin-pwa'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [
solid(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'Tasgrid',
short_name: 'Tasgrid',
description: 'Precision Productivity Task Manager',
theme_color: '#000000',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
}
})