Compare commits
4 Commits
a717095bd1
...
54a0da9896
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a0da9896 | |||
| e7b0967bb3 | |||
| 6aacea2150 | |||
| 6b8be90295 |
@@ -8,6 +8,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>Tasgrid</title>
|
<title>Tasgrid</title>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const FilterBar: Component = () => {
|
|||||||
|
|
||||||
const hasActiveFilters = () => {
|
const hasActiveFilters = () => {
|
||||||
const f = store.filter;
|
const f = store.filter;
|
||||||
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
|
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10 || f.editedToday;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
|
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
|
||||||
@@ -44,10 +44,25 @@ export const FilterBar: Component = () => {
|
|||||||
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
|
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
|
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
|
||||||
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
<div class="flex items-center gap-4">
|
||||||
<Search size={12} />
|
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||||
Filters & Search
|
<Search size={12} />
|
||||||
</h3>
|
Filters
|
||||||
|
</h3>
|
||||||
|
<div class="h-4 w-px bg-border/50" />
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
|
||||||
|
class={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-tight transition-all border",
|
||||||
|
store.filter.editedToday
|
||||||
|
? "bg-primary/10 text-primary border-primary/20"
|
||||||
|
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
|
||||||
|
Edited Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={hasActiveFilters()}>
|
<Show when={hasActiveFilters()}>
|
||||||
<Button
|
<Button
|
||||||
@@ -56,7 +71,7 @@ export const FilterBar: Component = () => {
|
|||||||
class="h-7 px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
class="h-7 px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
||||||
onClick={clearFilter}
|
onClick={clearFilter}
|
||||||
>
|
>
|
||||||
Clear All
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button
|
<Button
|
||||||
@@ -70,7 +85,7 @@ export const FilterBar: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls Grid */}
|
{/* Controls */}
|
||||||
<div class="p-4 space-y-6">
|
<div class="p-4 space-y-6">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
|||||||
)}>
|
)}>
|
||||||
{props.task.title}
|
{props.task.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
{props.task.content && (
|
||||||
|
<div class="text-[11px] sm:text-xs text-muted-foreground/60 line-clamp-1 mt-1 leading-tight whitespace-pre">
|
||||||
|
{(() => {
|
||||||
|
let html = props.task.content || "";
|
||||||
|
// Replace block tags with newlines first to preserve structure
|
||||||
|
html = html.replace(/<\/p>/gi, '\n')
|
||||||
|
.replace(/<\/div>/gi, '\n')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/li>/gi, '\n')
|
||||||
|
.replace(/<\/h[1-6]>/gi, '\n');
|
||||||
|
|
||||||
|
const tmp = document.createElement("DIV");
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
const text = tmp.textContent || tmp.innerText || "";
|
||||||
|
|
||||||
|
// Replace newlines (and surrounding whitespace) with a wide gap
|
||||||
|
// Using 5 non-breaking spaces worth of visual space
|
||||||
|
return text.trim().replace(/\s*[\r\n]+\s*/g, ' ');
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-1 min-w-0">
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-1 min-w-0">
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div class="flex items-center gap-1.5 shrink-0">
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
|
|||||||
+136
-20
@@ -1,8 +1,9 @@
|
|||||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
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 } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy } from "lucide-solid";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal } 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";
|
||||||
@@ -41,13 +42,22 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let debounceTimer: number | undefined;
|
let debounceTimer: number | undefined;
|
||||||
|
let pendingContent: string | null = null;
|
||||||
|
|
||||||
const updateTaskContent = (html: string) => {
|
const updateTaskContent = (html: string) => {
|
||||||
|
pendingContent = html;
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
updateTask(props.task.id, { content: html });
|
if (pendingContent !== null) {
|
||||||
|
updateTask(props.task.id, { content: pendingContent });
|
||||||
|
pendingContent = null;
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Removed onCleanup to avoid lockup
|
||||||
|
|
||||||
|
|
||||||
const updateTitle = (val: string) => {
|
const updateTitle = (val: string) => {
|
||||||
setTitle(val);
|
setTitle(val);
|
||||||
updateTask(props.task.id, { title: val });
|
updateTask(props.task.id, { title: val });
|
||||||
@@ -86,7 +96,16 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
|
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
|
<Sheet open={props.isOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
// Flash save before closing to avoid lockup
|
||||||
|
if (pendingContent !== null) {
|
||||||
|
updateTask(props.task.id, { content: pendingContent });
|
||||||
|
pendingContent = null;
|
||||||
|
}
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onOpenAutoFocus={(e) => {
|
onOpenAutoFocus={(e) => {
|
||||||
@@ -309,22 +328,119 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{/* Save as Template (Desktop + Mobile) */}
|
{/* More Options Menu */}
|
||||||
<Button
|
<Popover modal={true}>
|
||||||
variant="ghost"
|
<PopoverTrigger as={Button} variant="ghost" size="sm" class="h-9 w-9 p-0 rounded-xl border border-border/40 hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||||
size="sm"
|
<MoreHorizontal size={18} />
|
||||||
class="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
|
</PopoverTrigger>
|
||||||
onClick={async () => {
|
<PopoverContent class="w-72 p-0 overflow-hidden" align="end">
|
||||||
const name = prompt("Enter a name for this template:");
|
<div class="flex flex-col">
|
||||||
if (name) {
|
<div class="p-2 border-b border-border/50">
|
||||||
await saveTaskAsTemplate(props.task.id, name);
|
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
|
||||||
toast.success(`Template "${name}" created`);
|
<div class="flex flex-col gap-2 p-2 pt-0">
|
||||||
}
|
<Select
|
||||||
}}
|
value={props.task.recurrence?.type || "none"}
|
||||||
>
|
onChange={(val: string | null) => {
|
||||||
<Copy size={16} />
|
if (!val || val === "none") {
|
||||||
<span class="text-xs font-bold uppercase tracking-wider">Template</span>
|
updateTask(props.task.id, { recurrence: null as any });
|
||||||
</Button>
|
} else {
|
||||||
|
updateTask(props.task.id, {
|
||||||
|
recurrence: {
|
||||||
|
type: val as any,
|
||||||
|
days: val === 'weekly' ? [] : undefined,
|
||||||
|
dayOfMonth: val === 'monthly' ? 1 : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={["none", "daily", "weekly", "monthly"]}
|
||||||
|
placeholder="None"
|
||||||
|
itemComponent={(props: any) => (
|
||||||
|
<SelectItem item={props.item} class="capitalize">
|
||||||
|
{props.item.rawValue}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-8 text-xs capitalize">
|
||||||
|
<SelectValue<string>>{state => state.selectedOption() || "None"}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Weekly Options */}
|
||||||
|
{props.task.recurrence?.type === 'weekly' && (
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1 justify-between">
|
||||||
|
<For each={['S', 'M', 'T', 'W', 'T', 'F', 'S']}>
|
||||||
|
{(day, index) => (
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
"w-7 h-7 rounded-full text-[10px] font-bold flex items-center justify-center transition-colors shadow-sm",
|
||||||
|
props.task.recurrence?.days?.includes(index())
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const currentDays = props.task.recurrence?.days || [];
|
||||||
|
const newDays = currentDays.includes(index())
|
||||||
|
? currentDays.filter(d => d !== index())
|
||||||
|
: [...currentDays, index()].sort();
|
||||||
|
|
||||||
|
updateTask(props.task.id, {
|
||||||
|
recurrence: { ...props.task.recurrence!, days: newDays }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly Options */}
|
||||||
|
{props.task.recurrence?.type === 'monthly' && (
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs text-muted-foreground">Day of month:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
class="h-7 w-16 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
value={props.task.recurrence?.dayOfMonth || 1}
|
||||||
|
onInput={(e) => {
|
||||||
|
const val = parseInt(e.currentTarget.value);
|
||||||
|
if (!isNaN(val) && val >= 1 && val <= 31) {
|
||||||
|
updateTask(props.task.id, {
|
||||||
|
recurrence: { ...props.task.recurrence!, dayOfMonth: val }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="w-full justify-start h-8 px-2 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
const name = prompt("Enter a name for this template:");
|
||||||
|
if (name) {
|
||||||
|
await saveTaskAsTemplate(props.task.id, name);
|
||||||
|
toast.success(`Template "${name}" created`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
<span class="text-xs">Create Template from Task</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
{/* Close button (Mobile only) */}
|
{/* Close button (Mobile only) */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: "Type '/' for commands or start writing...",
|
placeholder: ({ editor }) => {
|
||||||
|
// Only show placeholder if first node is a paragraph and doc is empty/short
|
||||||
|
if (editor.state.doc.childCount > 1 || (editor.state.doc.firstChild && editor.state.doc.firstChild.type.name !== 'paragraph')) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "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",
|
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({
|
TaskList.configure({
|
||||||
@@ -65,7 +71,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
"[&_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']]: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']>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",
|
"[&_li[data-type='taskItem']>div]:flex-1 [&_li[data-type='taskItem']>div]:min-h-[1.5rem]",
|
||||||
props.class
|
props.class
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -92,7 +98,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
class="w-full h-full cursor-text"
|
class="w-full cursor-text"
|
||||||
onClick={() => editor()?.chain().focus().run()}
|
onClick={() => editor()?.chain().focus().run()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+203
-22
@@ -26,8 +26,85 @@ export interface Task {
|
|||||||
deletedAt?: number; // Timestamp of soft delete
|
deletedAt?: number; // Timestamp of soft delete
|
||||||
created: string; // ISO string from PB
|
created: string; // ISO string from PB
|
||||||
updated: string; // ISO string from PB
|
updated: string; // ISO string from PB
|
||||||
|
recurrence?: {
|
||||||
|
type: 'daily' | 'weekly' | 'monthly';
|
||||||
|
days?: number[]; // For weekly (0-6, 0=Sunday)
|
||||||
|
dayOfMonth?: number; // For monthly (1-31)
|
||||||
|
lastUncompleted?: string; // ISO date of last automatic reset
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const checkRecurringTasks = () => {
|
||||||
|
const tasks = store.tasks;
|
||||||
|
const nowObj = new Date();
|
||||||
|
const todayStr = nowObj.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
if (!task.completed || !task.recurrence) return;
|
||||||
|
|
||||||
|
// If deleted, we don't recurse?
|
||||||
|
if (task.deletedAt) return;
|
||||||
|
|
||||||
|
const { type, days, dayOfMonth, lastUncompleted } = task.recurrence;
|
||||||
|
let shouldReset = false;
|
||||||
|
|
||||||
|
// Check if we already reset it today/this cycle
|
||||||
|
if (lastUncompleted) {
|
||||||
|
const lastDate = new Date(lastUncompleted).toISOString().split('T')[0];
|
||||||
|
if (lastDate === todayStr) return; // Already reset today
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check 'updated' date. If it was marked completed TODAY, don't reset immediately.
|
||||||
|
// We only reset if the completion happened BEFORE the current cycle trigger.
|
||||||
|
// E.g. Completed yesterday, today is a new day -> Reset.
|
||||||
|
const updatedDate = new Date(task.updated).toISOString().split('T')[0];
|
||||||
|
if (updatedDate === todayStr) {
|
||||||
|
// If manual loop: user completes it, we shouldn't immediately uncomplete it.
|
||||||
|
// But what if it's supposed to be uncompleted today?
|
||||||
|
// "Tasks that 'uncomplete' at given intervals" implies:
|
||||||
|
// If I complete it today, it stays completed until the NEXT interval.
|
||||||
|
// So we only reset if it was completed BEFORE today (for daily).
|
||||||
|
if (type === 'daily') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'daily') {
|
||||||
|
// If completed before today, reset.
|
||||||
|
if (updatedDate < todayStr) {
|
||||||
|
shouldReset = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type === 'weekly') {
|
||||||
|
// If today is one of the recurrence days
|
||||||
|
const currentDay = nowObj.getDay();
|
||||||
|
if (days?.includes(currentDay)) {
|
||||||
|
// Reset if it was completed BEFORE today.
|
||||||
|
if (updatedDate < todayStr) {
|
||||||
|
shouldReset = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type === 'monthly') {
|
||||||
|
const currentDate = nowObj.getDate();
|
||||||
|
if (dayOfMonth === currentDate) {
|
||||||
|
if (updatedDate < todayStr) {
|
||||||
|
shouldReset = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReset) {
|
||||||
|
console.log(`Resetting recurring task: ${task.title}`);
|
||||||
|
updateTask(task.id, {
|
||||||
|
completed: false,
|
||||||
|
recurrence: {
|
||||||
|
...task.recurrence,
|
||||||
|
lastUncompleted: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export interface TaskTemplate {
|
export interface TaskTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,6 +135,7 @@ export interface Filter {
|
|||||||
priorityMax: number;
|
priorityMax: number;
|
||||||
urgencyMin: number;
|
urgencyMin: number;
|
||||||
urgencyMax: number;
|
urgencyMax: number;
|
||||||
|
editedToday: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskStore {
|
interface TaskStore {
|
||||||
@@ -85,7 +163,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
priorityMin: 1,
|
priorityMin: 1,
|
||||||
priorityMax: 10,
|
priorityMax: 10,
|
||||||
urgencyMin: 1,
|
urgencyMin: 1,
|
||||||
urgencyMax: 10
|
urgencyMax: 10,
|
||||||
|
editedToday: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +207,13 @@ export const matchesFilter = (task: Task) => {
|
|||||||
const urgency = calculateUrgencyFromDate(task.dueDate);
|
const urgency = calculateUrgencyFromDate(task.dueDate);
|
||||||
if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false;
|
if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false;
|
||||||
|
|
||||||
|
// Edited Today
|
||||||
|
if (f.editedToday) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const updated = new Date(task.updated).toISOString().split('T')[0];
|
||||||
|
if (today !== updated) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +287,94 @@ export const getCombinedScore = (task: Task): number => {
|
|||||||
|
|
||||||
let heartbeatInterval: number | undefined;
|
let heartbeatInterval: number | undefined;
|
||||||
|
|
||||||
|
const mapRecordToTask = (r: any): Task => {
|
||||||
|
// Check if it's a template? The caller handles filtering templates usually,
|
||||||
|
// but here we just map fields.
|
||||||
|
const tags = r.tags || [];
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
startDate: r.startDate,
|
||||||
|
dueDate: r.dueDate,
|
||||||
|
priority: r.priority,
|
||||||
|
completed: r.completed,
|
||||||
|
tags: tags,
|
||||||
|
content: r.content,
|
||||||
|
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||||
|
created: r.created,
|
||||||
|
updated: r.updated,
|
||||||
|
recurrence: r.recurrence
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeToRealtime = async () => {
|
||||||
|
// Unsubscribe first to avoid duplicates
|
||||||
|
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
|
||||||
|
|
||||||
|
pb.collection(TASGRID_COLLECTION).subscribe('*', (e) => {
|
||||||
|
console.log("Realtime event:", e.action, e.record.id);
|
||||||
|
|
||||||
|
if (e.action === 'create') {
|
||||||
|
// Check if it's a template
|
||||||
|
const isTemplate = e.record.tags?.includes("__template__");
|
||||||
|
if (isTemplate) {
|
||||||
|
// We don't auto-add templates to the main task list usually?
|
||||||
|
// Wait, `store.templates` is separate.
|
||||||
|
// We should handle templates too if we want sync on templates.
|
||||||
|
let meta: any = {};
|
||||||
|
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
||||||
|
const newTemplate: TaskTemplate = {
|
||||||
|
id: e.record.id,
|
||||||
|
name: e.record.title,
|
||||||
|
title: meta.title || "",
|
||||||
|
priority: e.record.priority,
|
||||||
|
urgency: meta.urgency || 5,
|
||||||
|
tags: meta.tags || [],
|
||||||
|
content: meta.content || ""
|
||||||
|
};
|
||||||
|
setStore("templates", t => [...(t || []), newTemplate]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular Task
|
||||||
|
const exists = store.tasks.find(t => t.id === e.record.id);
|
||||||
|
if (!exists) {
|
||||||
|
const newTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => [newTask, ...t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.action === 'update') {
|
||||||
|
// Is it a template?
|
||||||
|
const isTemplate = e.record.tags?.includes("__template__");
|
||||||
|
if (isTemplate) {
|
||||||
|
// Update template store
|
||||||
|
let meta: any = {};
|
||||||
|
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
||||||
|
setStore("templates", t => t.id === e.record.id, {
|
||||||
|
name: e.record.title,
|
||||||
|
title: meta.title || "",
|
||||||
|
priority: e.record.priority,
|
||||||
|
urgency: meta.urgency || 5,
|
||||||
|
tags: meta.tags || [],
|
||||||
|
content: meta.content || ""
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal Task
|
||||||
|
const updatedTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.action === 'delete') {
|
||||||
|
// Try deleting from both just in case
|
||||||
|
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||||
|
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const initStore = async () => {
|
export const initStore = async () => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
@@ -219,7 +393,10 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// Heartbeat - Ensure only one interval is running
|
// Heartbeat - Ensure only one interval is running
|
||||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||||
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
|
heartbeatInterval = setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.)
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
|
// 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
|
||||||
@@ -281,25 +458,19 @@ export const initStore = async () => {
|
|||||||
content: meta.content || ""
|
content: meta.content || ""
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
allTasks.push({
|
allTasks.push(mapRecordToTask(r));
|
||||||
id: r.id,
|
|
||||||
title: r.title,
|
|
||||||
startDate: r.startDate,
|
|
||||||
dueDate: r.dueDate,
|
|
||||||
priority: r.priority,
|
|
||||||
completed: r.completed,
|
|
||||||
tags: tags,
|
|
||||||
content: r.content,
|
|
||||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
|
||||||
created: r.created,
|
|
||||||
updated: r.updated
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setStore("tasks", reconcile(allTasks));
|
setStore("tasks", reconcile(allTasks));
|
||||||
setStore("templates", loadedTemplates);
|
setStore("templates", loadedTemplates);
|
||||||
|
|
||||||
|
// Check recurring tasks after load
|
||||||
|
checkRecurringTasks();
|
||||||
|
|
||||||
|
// 3. Subscribe to Realtime Updates
|
||||||
|
await subscribeToRealtime();
|
||||||
|
|
||||||
// 4. Reconstructive Migration: Verify all tags in tasks have definitions
|
// 4. Reconstructive Migration: Verify all tags in tasks have definitions
|
||||||
const foundTagNames = new Set<string>();
|
const foundTagNames = new Set<string>();
|
||||||
allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||||
@@ -369,12 +540,21 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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)
|
||||||
setStore("tasks", (t) => t.map(task => task.id === tempId ? {
|
setStore("tasks", (t) => {
|
||||||
...task,
|
// Check if REAL ID exists already (from subscription race)
|
||||||
id: record.id,
|
const realExists = t.some(x => x.id === record.id);
|
||||||
created: record.created,
|
if (realExists) {
|
||||||
updated: record.updated
|
// If real exists, just remove temp.
|
||||||
} : task));
|
return t.filter(x => x.id !== tempId);
|
||||||
|
}
|
||||||
|
// Otherwise, replace temp with real
|
||||||
|
return t.map(task => task.id === tempId ? {
|
||||||
|
...task,
|
||||||
|
id: record.id,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated
|
||||||
|
} : task);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("create failed", err);
|
console.error("create failed", err);
|
||||||
toast.error("Failed to save task.");
|
toast.error("Failed to save task.");
|
||||||
@@ -732,7 +912,8 @@ export const clearFilter = () => {
|
|||||||
priorityMin: 1,
|
priorityMin: 1,
|
||||||
priorityMax: 10,
|
priorityMax: 10,
|
||||||
urgencyMin: 1,
|
urgencyMin: 1,
|
||||||
urgencyMax: 10
|
urgencyMax: 10,
|
||||||
|
editedToday: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user