diff --git a/src/App.tsx b/src/App.tsx index cbe000e..edeacc9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 MatrixView = lazy(() => import('./views/MatrixView').then(m => ({ default: m.MatrixView }))); 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 = () => { // Basic routing state @@ -34,6 +36,8 @@ const App: Component = () => { import('./views/PriorityView'); import('./views/MatrixView'); import('./views/SettingsView'); + import('./views/SnowballView'); + import('./views/DigInView'); // Remove splash screen after high priority work is done const splash = document.getElementById('splash'); @@ -52,6 +56,8 @@ const App: Component = () => { + + diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index c21814f..0308d45 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,7 +1,8 @@ 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 { Button } from "./ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; interface NavItem { icon: any; @@ -9,29 +10,99 @@ interface NavItem { view: string; } -const navItems: NavItem[] = [ +// Desktop nav items (all) +const desktopNavItems: 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: 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" }, ]; 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 ( - - {(item) => ( - 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.label} - + + {(group) => ( + group.view && props.setView(group.view)} + 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" + )} + > + + {group.label} + + }> + + + + + + + {group.label} + + + + + {(item) => ( + props.setView(item.view)} + > + + {item.label} + + )} + + + + + )} @@ -81,7 +152,7 @@ export const Sidebar: Component<{ - i.view !== "settings")}> + {(item) => ( props.setView(item.view)} diff --git a/src/components/QuickEntry.tsx b/src/components/QuickEntry.tsx index ffea182..4a58348 100644 --- a/src/components/QuickEntry.tsx +++ b/src/components/QuickEntry.tsx @@ -1,6 +1,6 @@ import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js"; 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 { Badge } from "@/components/ui/badge"; import { TextField, TextFieldInput } from "@/components/ui/textfield"; @@ -11,7 +11,7 @@ import { getThemeAdjustedColor } from "@/lib/colors"; import { lazy, Suspense } from "solid-js"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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 }))); @@ -28,6 +28,7 @@ export const QuickEntry: Component = () => { const [tags, setTags] = createSignal([]); const [description, setDescription] = createSignal(""); const [editorInstance, setEditorInstance] = createSignal(null); + const [size, setSize] = createSignal("5"); // Reactive shorthand parsing createEffect(() => { @@ -205,7 +206,8 @@ export const QuickEntry: Component = () => { priority: finalPriority, dueDate: finalDueDate || undefined, tags: finalTags, - content: description() + content: description(), + size: parseInt(size()) }); setInput(""); @@ -213,6 +215,7 @@ export const QuickEntry: Component = () => { setUrgency("5"); setTags([]); setDescription(""); + setSize("5"); lastParsedTags = []; setDateString(""); setIsOpen(false); @@ -275,6 +278,7 @@ export const QuickEntry: Component = () => { + {/* Row 1: Priority, Size, Urgency */} Priority @@ -306,6 +310,36 @@ export const QuickEntry: Component = () => { + + Size + + 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) => ( + + {props.item.rawValue.label} + + )} + > + + + + + {SIZE_OPTIONS.find(o => o.value === size())?.label || size()} + + + + + + + Urgency (Time) @@ -332,8 +366,48 @@ export const QuickEntry: Component = () => { + - + {/* Row 2: Tags + Due Date */} + + + + + {(tag) => ( + { + 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))} + > + d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8" + }} + /> + {tag} + + )} + + + + Due Date { - - - - {(tag) => ( - { - 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))} - > - d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8" - }} - /> - {tag} - - )} - - - }> = (props) => { + {/* Size */} + + Size + + 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) => {props.item.rawValue.label}} + > + + + + + {SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 5).toString())?.label || props.task.size} + + + + + + + {/* Commands Shortcut */} = { 2: 2160, 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" } +]; diff --git a/src/store/index.ts b/src/store/index.ts index 9b2280f..6c623b7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -32,6 +32,7 @@ export interface Task { dayOfMonth?: number; // For monthly (1-31) lastUncompleted?: string; // ISO date of last automatic reset }; + size?: number; // 0-10, task complexity/size } 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; }; @@ -303,7 +309,8 @@ const mapRecordToTask = (r: any): Task => { deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined, created: r.created, 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; } - // Normal Task - const updatedTask = mapRecordToTask(e.record); - setStore("tasks", t => t.id === e.record.id, updatedTask); + // Normal Task - only apply if incoming record is newer or equal + const existingTask = store.tasks.find(t => t.id === e.record.id); + 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') { @@ -496,7 +518,7 @@ export const initStore = async () => { // -- 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; // 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 tags = options?.tags ?? ["work"]; const content = options?.content ?? ""; + const size = options?.size ?? 5; const tempId = "temp-" + Date.now(); const newTask: Task = { @@ -520,6 +543,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string completed: false, tags, content, + size, created: new Date().toISOString(), updated: new Date().toISOString() }; @@ -536,7 +560,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string priority, completed: false, tags, - content + content, + size }); // 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, dueDate: original.dueDate, tags: [...(original.tags || [])], - content: original.content + content: original.content, + size: original.size }); toast.success("Task duplicated"); @@ -581,8 +607,12 @@ export const copyTask = async (id: string) => { export const updateTask = async (id: string, updates: Partial) => { if (!pb.authStore.isValid) return; - // Optimistic - setStore("tasks", (t) => t.id === id, updates); + // Optimistic update with timestamp to prevent stale realtime events + const optimisticUpdates = { + ...updates, + updated: new Date().toISOString() + }; + setStore("tasks", (t) => t.id === id, optimisticUpdates); try { // 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) => { - // 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 handles both optimistic update and PB sync + // Pass undefined for deletedAt, updateTask will convert to null for PB updateTask(id, { deletedAt: undefined }); }; diff --git a/src/views/DigInView.tsx b/src/views/DigInView.tsx new file mode 100644 index 0000000..c91ae15 --- /dev/null +++ b/src/views/DigInView.tsx @@ -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 ( + + + Dig In + Largest tasks first. Tackle big projects head-on. + + + + + {(task) => } + + + + {sortedTasks().length === 0 && ( + + + ⛏️ + + Nothing here. Add some tasks to dig into! + + )} + + ); +}; diff --git a/src/views/SnowballView.tsx b/src/views/SnowballView.tsx new file mode 100644 index 0000000..0ee866c --- /dev/null +++ b/src/views/SnowballView.tsx @@ -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 ( + + + Snowball + Smallest tasks first. Build momentum with quick wins. + + + + + {(task) => } + + + + {sortedTasks().length === 0 && ( + + + ❄️ + + Nothing here. Add some tasks to start your snowball! + + )} + + ); +};
Largest tasks first. Tackle big projects head-on.
Nothing here. Add some tasks to dig into!
Smallest tasks first. Build momentum with quick wins.
Nothing here. Add some tasks to start your snowball!