diff --git a/src/components/StatusCircle.tsx b/src/components/StatusCircle.tsx index fc3c9ce..c8600d3 100644 --- a/src/components/StatusCircle.tsx +++ b/src/components/StatusCircle.tsx @@ -1,4 +1,4 @@ -import { type Component } from "solid-js"; +import { type Component, For } from "solid-js"; import { CheckCircle2 } from "lucide-solid"; import { cn } from "@/lib/utils"; @@ -6,6 +6,7 @@ interface StatusCircleProps { status: number; size?: number; className?: string; + recurrenceProgress?: number | null; } export const StatusCircle: Component = (props) => { @@ -25,10 +26,39 @@ export const StatusCircle: Component = (props) => { return circumference() * (1 - percentage); }; + const dotCount = () => 12; + const dotRadius = () => Math.max(1.4, size() * 0.06); + const dotRingRadius = () => radius() - 1; + const filledDots = () => { + const progress = props.recurrenceProgress ?? 0; + return Math.floor(Math.max(0, Math.min(1, progress)) * dotCount()); + }; + return (
{props.status >= 10 ? ( - + props.recurrenceProgress != null ? ( + + + {(_, index) => { + const angle = (index() / dotCount()) * Math.PI * 2 - Math.PI / 2; + const cx = size() / 2 + Math.cos(angle) * dotRingRadius(); + const cy = size() / 2 + Math.sin(angle) * dotRingRadius(); + const isFilled = index() < filledDots(); + return ( + + ); + }} + + + ) : ( + + ) ) : ( // Progress Circle
diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index 305398d..d6871eb 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -7,6 +7,7 @@ import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { getStatusOptionsForTask } from "@/lib/constants"; +import { getRecurrenceProgress } from "@/lib/recurrence"; import { StatusCircle } from "./StatusCircle"; export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => { @@ -54,6 +55,10 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) }); const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence)); + const recurrenceProgress = createMemo(() => { + if (props.task.status !== 10) return null; + return getRecurrenceProgress(props.task.recurrence, props.task.updated, now()); + }); return (
= (props) updateTask(props.task.id, { status: 10 }); }} > - + diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index 6988c09..9f92978 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -1,6 +1,6 @@ import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js"; import { Sheet, SheetContent } from "@/components/ui/sheet"; -import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } from "@/store"; +import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store"; 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, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film, FileText } from "lucide-solid"; @@ -17,6 +17,7 @@ import { lazy, Suspense } from "solid-js"; import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; import { pb } from "@/lib/pocketbase"; +import { getRecurrenceProgress } from "@/lib/recurrence"; const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -208,6 +209,10 @@ export const TaskDetail: Component = (props) => { }); const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence)); + const recurrenceProgress = createMemo(() => { + if (props.task.status !== 10) return null; + return getRecurrenceProgress(props.task.recurrence, props.task.updated, now()); + }); return ( { @@ -251,7 +256,7 @@ export const TaskDetail: Component = (props) => { updateStatus("10"); }} > - + diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts new file mode 100644 index 0000000..79d2962 --- /dev/null +++ b/src/lib/recurrence.ts @@ -0,0 +1,67 @@ +export type Recurrence = { + type: 'daily' | 'weekly' | 'monthly'; + days?: number[]; + dayOfMonth?: number; +}; + +const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +const addDays = (date: Date, days: number) => { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +}; + +const getNextDaily = (completedAt: Date) => addDays(startOfDay(completedAt), 1); + +const getNextWeekly = (completedAt: Date, days: number[]) => { + if (!days || days.length === 0) return null; + const base = startOfDay(completedAt); + for (let offset = 1; offset <= 7; offset++) { + const candidate = addDays(base, offset); + if (days.includes(candidate.getDay())) return candidate; + } + return null; +}; + +const getNextMonthly = (completedAt: Date, dayOfMonth?: number) => { + if (!dayOfMonth || dayOfMonth < 1 || dayOfMonth > 31) return null; + const base = startOfDay(completedAt); + const baseDay = base.getDate(); + const startOffset = dayOfMonth > baseDay ? 0 : 1; + + for (let offset = startOffset; offset <= 12; offset++) { + const year = base.getFullYear(); + const monthIndex = base.getMonth() + offset; + const candidate = new Date(year, monthIndex, dayOfMonth); + if (candidate.getMonth() === (monthIndex % 12 + 12) % 12) { + if (candidate.getTime() > completedAt.getTime()) { + return startOfDay(candidate); + } + } + } + return null; +}; + +export const getNextRecurrenceDate = (recurrence: Recurrence, completedAt: Date) => { + if (recurrence.type === 'daily') return getNextDaily(completedAt); + if (recurrence.type === 'weekly') return getNextWeekly(completedAt, recurrence.days || []); + if (recurrence.type === 'monthly') return getNextMonthly(completedAt, recurrence.dayOfMonth); + return null; +}; + +export const getRecurrenceProgress = ( + recurrence: Recurrence | null | undefined, + completedAtIso: string | null | undefined, + nowMs: number +) => { + if (!recurrence || !completedAtIso) return null; + const completedAt = new Date(completedAtIso); + if (isNaN(completedAt.getTime())) return null; + const next = getNextRecurrenceDate(recurrence, completedAt); + if (!next) return null; + const total = next.getTime() - completedAt.getTime(); + if (total <= 0) return null; + const progress = (nowMs - completedAt.getTime()) / total; + return Math.max(0, Math.min(1, progress)); +};