added Recurrence feature
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
<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="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" />
|
||||
<title>Tasgrid</title>
|
||||
<script>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy } from "lucide-solid";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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 { Button } from "./ui/button";
|
||||
import { TagPicker } from "./TagPicker";
|
||||
@@ -309,11 +310,104 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Save as Template (Desktop + Mobile) */}
|
||||
{/* More Options Menu */}
|
||||
<Popover modal={true}>
|
||||
<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">
|
||||
<MoreHorizontal size={18} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-0 overflow-hidden" align="end">
|
||||
<div class="flex flex-col">
|
||||
<div class="p-2 border-b border-border/50">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
|
||||
<div class="flex flex-col gap-2 p-2 pt-0">
|
||||
<Select
|
||||
value={props.task.recurrence?.type || "none"}
|
||||
onChange={(val: string | null) => {
|
||||
if (!val || val === "none") {
|
||||
updateTask(props.task.id, { recurrence: null as any });
|
||||
} 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="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
|
||||
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) {
|
||||
@@ -322,9 +416,13 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Template</span>
|
||||
<Copy size={14} />
|
||||
<span class="text-xs">Create Template from Task</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Close button (Mobile only) */}
|
||||
<Button
|
||||
|
||||
@@ -26,8 +26,85 @@ export interface Task {
|
||||
deletedAt?: number; // Timestamp of soft delete
|
||||
created: 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -309,6 +386,9 @@ export const initStore = async () => {
|
||||
setStore("tasks", reconcile(allTasks));
|
||||
setStore("templates", loadedTemplates);
|
||||
|
||||
// Check recurring tasks after load
|
||||
checkRecurringTasks();
|
||||
|
||||
// 4. Reconstructive Migration: Verify all tags in tasks have definitions
|
||||
const foundTagNames = new Set<string>();
|
||||
allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||
|
||||
Reference in New Issue
Block a user