added task size management features and fixed trash recover feature
This commit is contained in:
@@ -10,6 +10,8 @@ const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ defaul
|
|||||||
const PriorityView = lazy(() => import('./views/PriorityView').then(m => ({ default: m.PriorityView })));
|
const PriorityView = lazy(() => import('./views/PriorityView').then(m => ({ default: m.PriorityView })));
|
||||||
const MatrixView = lazy(() => import('./views/MatrixView').then(m => ({ default: m.MatrixView })));
|
const MatrixView = lazy(() => import('./views/MatrixView').then(m => ({ default: m.MatrixView })));
|
||||||
const SettingsView = lazy(() => import('./views/SettingsView').then(m => ({ default: m.SettingsView })));
|
const SettingsView = lazy(() => import('./views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||||
|
const SnowballView = lazy(() => import('./views/SnowballView').then(m => ({ default: m.SnowballView })));
|
||||||
|
const DigInView = lazy(() => import('./views/DigInView').then(m => ({ default: m.DigInView })));
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
// Basic routing state
|
// Basic routing state
|
||||||
@@ -34,6 +36,8 @@ const App: Component = () => {
|
|||||||
import('./views/PriorityView');
|
import('./views/PriorityView');
|
||||||
import('./views/MatrixView');
|
import('./views/MatrixView');
|
||||||
import('./views/SettingsView');
|
import('./views/SettingsView');
|
||||||
|
import('./views/SnowballView');
|
||||||
|
import('./views/DigInView');
|
||||||
|
|
||||||
// Remove splash screen after high priority work is done
|
// Remove splash screen after high priority work is done
|
||||||
const splash = document.getElementById('splash');
|
const splash = document.getElementById('splash');
|
||||||
@@ -52,6 +56,8 @@ const App: Component = () => {
|
|||||||
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
|
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
|
||||||
<Show when={currentView() === "priority"}><PriorityView /></Show>
|
<Show when={currentView() === "priority"}><PriorityView /></Show>
|
||||||
<Show when={currentView() === "matrix"}><MatrixView /></Show>
|
<Show when={currentView() === "matrix"}><MatrixView /></Show>
|
||||||
|
<Show when={currentView() === "snowball"}><SnowballView /></Show>
|
||||||
|
<Show when={currentView() === "dig_in"}><DigInView /></Show>
|
||||||
<Show when={currentView() === "settings"}><SettingsView /></Show>
|
<Show when={currentView() === "settings"}><SettingsView /></Show>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { type Component, For, Show } from "solid-js";
|
import { type Component, For, Show } from "solid-js";
|
||||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose } from "lucide-solid";
|
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp } from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
icon: any;
|
icon: any;
|
||||||
@@ -9,29 +10,99 @@ interface NavItem {
|
|||||||
view: string;
|
view: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
// Desktop nav items (all)
|
||||||
|
const desktopNavItems: NavItem[] = [
|
||||||
{ icon: ListTodo, label: "Focus", view: "critical" },
|
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||||
{ icon: Clock, label: "Time", view: "urgency" },
|
{ icon: Clock, label: "Time", view: "urgency" },
|
||||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||||
|
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||||
|
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile nav groups
|
||||||
|
interface MobileNavGroup {
|
||||||
|
icon: any;
|
||||||
|
label: string;
|
||||||
|
items?: NavItem[];
|
||||||
|
view?: string; // Direct navigation (no sub-items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileNavGroups: MobileNavGroup[] = [
|
||||||
|
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||||
|
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||||
|
{
|
||||||
|
icon: TrendingUp, label: "Value", items: [
|
||||||
|
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||||
|
{ icon: Clock, label: "Time", view: "urgency" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Gauge, label: "Flow", items: [
|
||||||
|
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||||
|
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
||||||
|
]
|
||||||
|
},
|
||||||
{ icon: Settings, label: "Settings", view: "settings" },
|
{ icon: Settings, label: "Settings", view: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BottomNav: Component<{ currentView: string; setView: (v: string) => void }> = (props) => {
|
export const BottomNav: Component<{ currentView: string; setView: (v: string) => void }> = (props) => {
|
||||||
|
const isGroupActive = (group: MobileNavGroup) => {
|
||||||
|
if (group.view) return props.currentView === group.view;
|
||||||
|
return group.items?.some(item => props.currentView === item.view) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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}>
|
<For each={mobileNavGroups}>
|
||||||
{(item) => (
|
{(group) => (
|
||||||
<button
|
<Show when={group.items} fallback={
|
||||||
onClick={() => props.setView(item.view)}
|
<button
|
||||||
class={cn(
|
onClick={() => group.view && props.setView(group.view)}
|
||||||
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
class={cn(
|
||||||
props.currentView === item.view ? "text-primary" : "text-muted-foreground"
|
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
||||||
)}
|
isGroupActive(group) ? "text-primary" : "text-muted-foreground"
|
||||||
>
|
)}
|
||||||
<item.icon size={20} />
|
>
|
||||||
<span class="text-[10px] font-medium uppercase tracking-wider">{item.label}</span>
|
<group.icon size={20} />
|
||||||
</button>
|
<span class="text-[10px] font-medium uppercase tracking-wider">{group.label}</span>
|
||||||
|
</button>
|
||||||
|
}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
class={cn(
|
||||||
|
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
||||||
|
isGroupActive(group) ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<group.icon size={20} />
|
||||||
|
<ChevronDown size={10} class="absolute -right-2 -bottom-0.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-wider">{group.label}</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-40 p-1 bg-card border-border shadow-xl mb-2">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left group",
|
||||||
|
props.currentView === item.view
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => props.setView(item.view)}
|
||||||
|
>
|
||||||
|
<item.icon size={16} />
|
||||||
|
<span class="text-sm font-medium">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -81,7 +152,7 @@ export const Sidebar: Component<{
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-3 space-y-1 mt-2">
|
<nav class="flex-1 px-3 space-y-1 mt-2">
|
||||||
<For each={navItems.filter(i => i.view !== "settings")}>
|
<For each={desktopNavItems}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => props.setView(item.view)}
|
onClick={() => props.setView(item.view)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
|
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
|
||||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
|
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
|
||||||
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown } from "lucide-solid";
|
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown, Gauge } from "lucide-solid";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||||
@@ -11,7 +11,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
|
|||||||
import { lazy, Suspense } from "solid-js";
|
import { lazy, Suspense } from "solid-js";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
|
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
|
||||||
|
|
||||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const QuickEntry: Component = () => {
|
|||||||
const [tags, setTags] = createSignal<string[]>([]);
|
const [tags, setTags] = createSignal<string[]>([]);
|
||||||
const [description, setDescription] = createSignal("");
|
const [description, setDescription] = createSignal("");
|
||||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||||
|
const [size, setSize] = createSignal<string>("5");
|
||||||
|
|
||||||
// Reactive shorthand parsing
|
// Reactive shorthand parsing
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -205,7 +206,8 @@ export const QuickEntry: Component = () => {
|
|||||||
priority: finalPriority,
|
priority: finalPriority,
|
||||||
dueDate: finalDueDate || undefined,
|
dueDate: finalDueDate || undefined,
|
||||||
tags: finalTags,
|
tags: finalTags,
|
||||||
content: description()
|
content: description(),
|
||||||
|
size: parseInt(size())
|
||||||
});
|
});
|
||||||
|
|
||||||
setInput("");
|
setInput("");
|
||||||
@@ -213,6 +215,7 @@ export const QuickEntry: Component = () => {
|
|||||||
setUrgency("5");
|
setUrgency("5");
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setSize("5");
|
||||||
lastParsedTags = [];
|
lastParsedTags = [];
|
||||||
setDateString("");
|
setDateString("");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -275,6 +278,7 @@ export const QuickEntry: Component = () => {
|
|||||||
</TextField>
|
</TextField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 1: Priority, Size, Urgency */}
|
||||||
<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 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">
|
<div class="flex-1 space-y-1.5">
|
||||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Priority</label>
|
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Priority</label>
|
||||||
@@ -306,6 +310,36 @@ export const QuickEntry: Component = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Size</label>
|
||||||
|
<Select<any>
|
||||||
|
value={size()}
|
||||||
|
onChange={(val) => {
|
||||||
|
const v = typeof val === 'object' ? val.value : val;
|
||||||
|
if (v) setSize(v);
|
||||||
|
}}
|
||||||
|
options={SIZE_OPTIONS}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
placeholder="Size"
|
||||||
|
itemComponent={(props) => (
|
||||||
|
<SelectItem item={props.item}>
|
||||||
|
{props.item.rawValue.label}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 truncate">
|
||||||
|
<Gauge size={14} class="text-primary shrink-0" />
|
||||||
|
<span class="text-sm font-medium truncate">
|
||||||
|
{SIZE_OPTIONS.find(o => o.value === size())?.label || size()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 space-y-1.5">
|
<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>
|
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Urgency (Time)</label>
|
||||||
<Select<any>
|
<Select<any>
|
||||||
@@ -332,8 +366,48 @@ export const QuickEntry: Component = () => {
|
|||||||
<SelectContent />
|
<SelectContent />
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 space-y-1.5">
|
{/* Row 2: Tags + Due Date */}
|
||||||
|
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
|
||||||
|
<div class="flex-1 flex flex-wrap gap-2 items-center">
|
||||||
|
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
||||||
|
<For each={tags()}>
|
||||||
|
{(tag) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||||
|
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={() => setTags(tags().filter(t => t !== tag))}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
|
||||||
|
style={{
|
||||||
|
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full md:w-auto md:min-w-[200px] space-y-1.5">
|
||||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
|
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
@@ -353,43 +427,6 @@ export const QuickEntry: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-4 bg-muted/10 border-b border-border flex flex-wrap gap-2 items-center">
|
|
||||||
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
|
||||||
<For each={tags()}>
|
|
||||||
{(tag) => (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
|
||||||
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={() => setTags(tags().filter(t => t !== tag))}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
|
|
||||||
style={{
|
|
||||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-3 bg-card border-b border-border min-h-[120px] max-h-[300px] overflow-y-auto">
|
<div class="px-4 py-3 bg-card border-b border-border min-h-[120px] max-h-[300px] overflow-y-auto">
|
||||||
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||||
<TaskEditor
|
<TaskEditor
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal } from "lucide-solid";
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge } from "lucide-solid";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { TagPicker } from "./TagPicker";
|
import { TagPicker } from "./TagPicker";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
|
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
|
||||||
import { store } from "@/store";
|
import { store } from "@/store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
@@ -189,6 +189,36 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<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">Size</span>
|
||||||
|
<Select<any>
|
||||||
|
value={(props.task.size ?? 5).toString()}
|
||||||
|
onChange={(val: any) => {
|
||||||
|
const value = typeof val === 'object' ? val?.value : val;
|
||||||
|
if (value) {
|
||||||
|
const s = parseInt(value);
|
||||||
|
if (!isNaN(s)) updateTask(props.task.id, { size: s });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={SIZE_OPTIONS}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
placeholder="Size"
|
||||||
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
|
||||||
|
<Gauge size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
|
<span class="text-xs sm:text-sm truncate">
|
||||||
|
{SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 5).toString())?.label || props.task.size}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Commands Shortcut */}
|
{/* Commands Shortcut */}
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -35,3 +35,17 @@ export const URGENCY_HOURS: Record<number, number> = {
|
|||||||
2: 2160,
|
2: 2160,
|
||||||
1: 4320
|
1: 4320
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SIZE_OPTIONS = [
|
||||||
|
{ value: "10", label: "10 - Huge" },
|
||||||
|
{ value: "9", label: "9" },
|
||||||
|
{ value: "8", label: "8" },
|
||||||
|
{ value: "7", label: "7" },
|
||||||
|
{ value: "6", label: "6" },
|
||||||
|
{ value: "5", label: "5" },
|
||||||
|
{ value: "4", label: "4" },
|
||||||
|
{ value: "3", label: "3" },
|
||||||
|
{ value: "2", label: "2" },
|
||||||
|
{ value: "1", label: "1" },
|
||||||
|
{ value: "0", label: "0 - Tiny" }
|
||||||
|
];
|
||||||
|
|||||||
+41
-15
@@ -32,6 +32,7 @@ export interface Task {
|
|||||||
dayOfMonth?: number; // For monthly (1-31)
|
dayOfMonth?: number; // For monthly (1-31)
|
||||||
lastUncompleted?: string; // ISO date of last automatic reset
|
lastUncompleted?: string; // ISO date of last automatic reset
|
||||||
};
|
};
|
||||||
|
size?: number; // 0-10, task complexity/size
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkRecurringTasks = () => {
|
export const checkRecurringTasks = () => {
|
||||||
@@ -280,6 +281,11 @@ export const getCombinedScore = (task: Task): number => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Size adjustment: (size - 5) * -0.4
|
||||||
|
// Small tasks (0-4) get a slight boost, large tasks (6-10) get a slight penalty
|
||||||
|
const sizeScore = ((task.size ?? 5) - 5) * -0.4;
|
||||||
|
baseScore += sizeScore;
|
||||||
|
|
||||||
return baseScore;
|
return baseScore;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,7 +309,8 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||||
created: r.created,
|
created: r.created,
|
||||||
updated: r.updated,
|
updated: r.updated,
|
||||||
recurrence: r.recurrence
|
recurrence: r.recurrence,
|
||||||
|
size: (r.size === null || r.size === undefined) ? undefined : r.size
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,9 +369,24 @@ export const subscribeToRealtime = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal Task
|
// Normal Task - only apply if incoming record is newer or equal
|
||||||
const updatedTask = mapRecordToTask(e.record);
|
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
||||||
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
if (existingTask) {
|
||||||
|
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||||
|
const localUpdated = new Date(existingTask.updated).getTime();
|
||||||
|
|
||||||
|
// Only apply update if incoming is newer (or we don't have a timestamp)
|
||||||
|
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
||||||
|
const updatedTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||||
|
} else {
|
||||||
|
console.log("Skipping stale realtime update for task:", e.record.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Task doesn't exist locally, add it
|
||||||
|
const updatedTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => [updatedTask, ...t]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.action === 'delete') {
|
if (e.action === 'delete') {
|
||||||
@@ -496,7 +518,7 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// -- Actions --
|
// -- Actions --
|
||||||
|
|
||||||
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string }) => {
|
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
// Default to ~24h from now if no date provided
|
// Default to ~24h from now if no date provided
|
||||||
@@ -509,6 +531,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
const priority = options?.priority ?? 5;
|
const priority = options?.priority ?? 5;
|
||||||
const tags = options?.tags ?? ["work"];
|
const tags = options?.tags ?? ["work"];
|
||||||
const content = options?.content ?? "";
|
const content = options?.content ?? "";
|
||||||
|
const size = options?.size ?? 5;
|
||||||
|
|
||||||
const tempId = "temp-" + Date.now();
|
const tempId = "temp-" + Date.now();
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
@@ -520,6 +543,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
completed: false,
|
completed: false,
|
||||||
tags,
|
tags,
|
||||||
content,
|
content,
|
||||||
|
size,
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
updated: new Date().toISOString()
|
updated: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -536,7 +560,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
priority,
|
priority,
|
||||||
completed: false,
|
completed: false,
|
||||||
tags,
|
tags,
|
||||||
content
|
content,
|
||||||
|
size
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace temp task with real record (merging server fields like id, created, updated)
|
// Replace temp task with real record (merging server fields like id, created, updated)
|
||||||
@@ -572,7 +597,8 @@ export const copyTask = async (id: string) => {
|
|||||||
priority: original.priority,
|
priority: original.priority,
|
||||||
dueDate: original.dueDate,
|
dueDate: original.dueDate,
|
||||||
tags: [...(original.tags || [])],
|
tags: [...(original.tags || [])],
|
||||||
content: original.content
|
content: original.content,
|
||||||
|
size: original.size
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Task duplicated");
|
toast.success("Task duplicated");
|
||||||
@@ -581,8 +607,12 @@ export const copyTask = async (id: string) => {
|
|||||||
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
// Optimistic
|
// Optimistic update with timestamp to prevent stale realtime events
|
||||||
setStore("tasks", (t) => t.id === id, updates);
|
const optimisticUpdates = {
|
||||||
|
...updates,
|
||||||
|
updated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setStore("tasks", (t) => t.id === id, optimisticUpdates);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
|
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
|
||||||
@@ -674,12 +704,8 @@ export const removeTask = (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restoreTask = (id: string) => {
|
export const restoreTask = (id: string) => {
|
||||||
// This requires passing undefined or null to updateTask
|
// updateTask handles both optimistic update and PB sync
|
||||||
// createStore handles undefined ok.
|
// Pass undefined for deletedAt, updateTask will convert to null for PB
|
||||||
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 });
|
updateTask(id, { deletedAt: undefined });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
|
||||||
|
export const DigInView: Component = () => {
|
||||||
|
const sortedTasks = createMemo(() => {
|
||||||
|
return [...store.tasks]
|
||||||
|
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
// Primary: Size descending (largest first)
|
||||||
|
const sizeA = a.size ?? 5;
|
||||||
|
const sizeB = b.size ?? 5;
|
||||||
|
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||||
|
// Secondary: Combined score descending (higher focus first)
|
||||||
|
return getCombinedScore(b) - getCombinedScore(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Dig In</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={sortedTasks()}>
|
||||||
|
{(task) => <TaskCard task={task} />}
|
||||||
|
</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">Nothing here. Add some tasks to dig into!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||||
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
|
||||||
|
export const SnowballView: Component = () => {
|
||||||
|
const sortedTasks = createMemo(() => {
|
||||||
|
return [...store.tasks]
|
||||||
|
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
// Primary: Size ascending (smallest first)
|
||||||
|
const sizeA = a.size ?? 5;
|
||||||
|
const sizeB = b.size ?? 5;
|
||||||
|
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||||
|
// Secondary: Combined score descending (higher focus first)
|
||||||
|
return getCombinedScore(b) - getCombinedScore(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Snowball</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={sortedTasks()}>
|
||||||
|
{(task) => <TaskCard task={task} />}
|
||||||
|
</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">Nothing here. Add some tasks to start your snowball!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user