mirror of
https://github.com/AnoyRC/priospace.git
synced 2026-09-01 15:35:34 +08:00
Added Timer and Habit Section with few bug fixes
This commit is contained in:
+22
@@ -10,6 +10,8 @@ import { Timer, Plus, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AddTaskModal } from "@/components/add-task-modal";
|
||||
import { TaskOptionsModal } from "@/components/task-options-modal";
|
||||
import { HabitTracker } from "@/components/habit-tracker";
|
||||
import { TimerModal } from "@/components/timer-modal";
|
||||
|
||||
export default function Home() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
@@ -413,6 +415,26 @@ export default function Home() {
|
||||
onToggleTask={toggleTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHabits && (
|
||||
<HabitTracker
|
||||
habits={habits}
|
||||
customTags={customTags}
|
||||
onClose={() => setShowHabits(false)}
|
||||
onUpdateHabits={setHabits}
|
||||
onAddCustomTag={addCustomTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTimer && (
|
||||
<TimerModal
|
||||
tasks={allTasks}
|
||||
onClose={() => setShowTimer(false)}
|
||||
onUpdateTaskTime={updateTaskTime}
|
||||
onUpdateTaskFocusTime={updateTaskFocusTime}
|
||||
onToggleTask={toggleTask}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Plus, Tag, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -22,6 +22,8 @@ const PRESET_COLORS = [
|
||||
"#8b5cf6", // purple
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
"#a85520", // brown
|
||||
"#6366f1", // indigo
|
||||
];
|
||||
|
||||
export function AddTaskModal({
|
||||
@@ -36,6 +38,54 @@ export function AddTaskModal({
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
||||
|
||||
// Arrow key navigation for categories
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
||||
if (customTags.length === 0) return;
|
||||
|
||||
const currentIndex = customTags.findIndex(
|
||||
(tag) => tag.id === selectedTag
|
||||
);
|
||||
let newIndex;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (currentIndex === -1) {
|
||||
// No selection, select first tag
|
||||
newIndex = 0;
|
||||
} else if (currentIndex === customTags.length - 1) {
|
||||
// At last tag, clear selection
|
||||
setSelectedTag("");
|
||||
return;
|
||||
} else {
|
||||
// Go to next tag
|
||||
newIndex = currentIndex + 1;
|
||||
}
|
||||
} else {
|
||||
// ArrowUp
|
||||
if (currentIndex === -1) {
|
||||
// No selection, select last tag
|
||||
newIndex = customTags.length - 1;
|
||||
} else if (currentIndex === 0) {
|
||||
// At first tag, clear selection
|
||||
setSelectedTag("");
|
||||
return;
|
||||
} else {
|
||||
// Go to previous tag
|
||||
newIndex = currentIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedTag(customTags[newIndex].id);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [customTags, selectedTag]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (taskTitle.trim()) {
|
||||
onAddTask(taskTitle.trim(), selectedTag || undefined);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function Number({ mv, number, height }) {
|
||||
const y = useTransform(mv, (latest) => {
|
||||
const placeValue = latest % 10;
|
||||
const offset = (10 + number - placeValue) % 10;
|
||||
let memo = offset * height;
|
||||
if (offset > 5) {
|
||||
memo -= 10 * height;
|
||||
}
|
||||
return memo;
|
||||
});
|
||||
|
||||
const style = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
return <motion.span style={{ ...style, y }}>{number}</motion.span>;
|
||||
}
|
||||
|
||||
function Digit({ place, value, height, digitStyle }) {
|
||||
const valueRoundedToPlace = Math.floor(value / place);
|
||||
const animatedValue = useSpring(valueRoundedToPlace);
|
||||
|
||||
useEffect(() => {
|
||||
animatedValue.set(valueRoundedToPlace);
|
||||
}, [animatedValue, valueRoundedToPlace]);
|
||||
|
||||
const defaultStyle = {
|
||||
height,
|
||||
position: "relative",
|
||||
width: "1ch",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...defaultStyle, ...digitStyle }}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<Number key={i} mv={animatedValue} number={i} height={height} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CountdownTimer = ({
|
||||
value,
|
||||
fontSize = 32,
|
||||
padding = 0,
|
||||
gap = 4,
|
||||
borderRadius = 4,
|
||||
horizontalPadding = 8,
|
||||
textColor = "#3b82f6",
|
||||
fontWeight = "bold",
|
||||
containerStyle,
|
||||
counterStyle,
|
||||
digitStyle,
|
||||
gradientHeight = 16,
|
||||
gradientFrom = "transparent",
|
||||
gradientTo = "transparent",
|
||||
topGradientStyle,
|
||||
bottomGradientStyle,
|
||||
}) => {
|
||||
const height = fontSize + padding;
|
||||
|
||||
// Convert seconds to MM:SS format
|
||||
const minutes = Math.floor(value / 60);
|
||||
const seconds = value % 60;
|
||||
|
||||
const defaultContainerStyle = {
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
};
|
||||
|
||||
const defaultCounterStyle = {
|
||||
fontSize,
|
||||
display: "flex",
|
||||
gap: gap,
|
||||
overflow: "hidden",
|
||||
borderRadius: borderRadius,
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding,
|
||||
lineHeight: 1,
|
||||
color: textColor,
|
||||
fontWeight: fontWeight,
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const colonStyle = {
|
||||
fontSize,
|
||||
color: textColor,
|
||||
fontWeight: fontWeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "0.5ch",
|
||||
};
|
||||
|
||||
const gradientContainerStyle = {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
};
|
||||
|
||||
const defaultTopGradientStyle = {
|
||||
height: gradientHeight,
|
||||
background: `linear-gradient(to bottom, ${gradientFrom}, ${gradientTo})`,
|
||||
};
|
||||
|
||||
const defaultBottomGradientStyle = {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
height: gradientHeight,
|
||||
background: `linear-gradient(to top, ${gradientFrom}, ${gradientTo})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...defaultContainerStyle, ...containerStyle }}>
|
||||
<div style={{ ...defaultCounterStyle, ...counterStyle }}>
|
||||
{/* Minutes - tens place */}
|
||||
<Digit
|
||||
place={10}
|
||||
value={minutes}
|
||||
height={height}
|
||||
digitStyle={digitStyle}
|
||||
/>
|
||||
{/* Minutes - ones place */}
|
||||
<Digit
|
||||
place={1}
|
||||
value={minutes}
|
||||
height={height}
|
||||
digitStyle={digitStyle}
|
||||
/>
|
||||
{/* Colon separator */}
|
||||
<div style={colonStyle}>:</div>
|
||||
{/* Seconds - tens place */}
|
||||
<Digit
|
||||
place={10}
|
||||
value={seconds}
|
||||
height={height}
|
||||
digitStyle={digitStyle}
|
||||
/>
|
||||
{/* Seconds - ones place */}
|
||||
<Digit
|
||||
place={1}
|
||||
value={seconds}
|
||||
height={height}
|
||||
digitStyle={digitStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={gradientContainerStyle}>
|
||||
<div
|
||||
style={topGradientStyle ? topGradientStyle : defaultTopGradientStyle}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
bottomGradientStyle
|
||||
? bottomGradientStyle
|
||||
: defaultBottomGradientStyle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,831 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#ef4444", // red
|
||||
"#f97316", // orange
|
||||
"#eab308", // yellow
|
||||
"#22c55e", // green
|
||||
"#3b82f6", // blue
|
||||
"#8b5cf6", // purple
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
"#a85520", // brown
|
||||
"#6366f1", // indigo
|
||||
];
|
||||
|
||||
export function HabitTracker({
|
||||
habits,
|
||||
customTags,
|
||||
onClose,
|
||||
onUpdateHabits,
|
||||
onAddCustomTag,
|
||||
}) {
|
||||
const [newHabitName, setNewHabitName] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showAddTag, setShowAddTag] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
||||
const [currentHabitIndex, setCurrentHabitIndex] = useState(-1); // -1 for overview, 0+ for individual habits
|
||||
const [showNavigation, setShowNavigation] = useState(true);
|
||||
|
||||
// Generate past 30 days (exactly 30 for 6x5 grid)
|
||||
const generatePastDays = () => {
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
days.push(date.toISOString().split("T")[0]);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
const pastDays = generatePastDays();
|
||||
|
||||
// Calculate daily habit completion counts
|
||||
const getDailyHabitCount = (date) => {
|
||||
return habits.reduce((count, habit) => {
|
||||
return count + (habit.completedDates.includes(date) ? 1 : 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Get intensity color based on habit count (0-5+ habits)
|
||||
const getOverviewIntensityColor = (count) => {
|
||||
if (count === 0) return "bg-gray-100 dark:bg-gray-800";
|
||||
if (count === 1) return "bg-green-200 dark:bg-green-900";
|
||||
if (count === 2) return "bg-green-300 dark:bg-green-800";
|
||||
if (count === 3) return "bg-green-400 dark:bg-green-700";
|
||||
if (count === 4) return "bg-green-500 dark:bg-green-600";
|
||||
return "bg-green-600 dark:bg-green-500"; // 5+ habits
|
||||
};
|
||||
|
||||
const addHabit = () => {
|
||||
if (newHabitName.trim()) {
|
||||
const newHabit = {
|
||||
id: Date.now().toString(),
|
||||
name: newHabitName.trim(),
|
||||
completedDates: [],
|
||||
tag: selectedTag || undefined,
|
||||
};
|
||||
onUpdateHabits([...habits, newHabit]);
|
||||
setNewHabitName("");
|
||||
setSelectedTag("");
|
||||
setShowAddForm(false);
|
||||
setShowNavigation(true);
|
||||
setCurrentHabitIndex(habits.length); // Switch to the new habit
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (newTagName.trim()) {
|
||||
const newTagId = onAddCustomTag(newTagName.trim(), selectedColor);
|
||||
setSelectedTag(newTagId);
|
||||
setNewTagName("");
|
||||
setShowAddTag(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHabit = (habitId) => {
|
||||
const updatedHabits = habits.filter((habit) => habit.id !== habitId);
|
||||
onUpdateHabits(updatedHabits);
|
||||
if (currentHabitIndex >= updatedHabits.length) {
|
||||
setCurrentHabitIndex(Math.max(-1, updatedHabits.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHabitDay = (habitId, date) => {
|
||||
const updatedHabits = habits.map((habit) => {
|
||||
if (habit.id === habitId) {
|
||||
const completedDates = habit.completedDates.includes(date)
|
||||
? habit.completedDates.filter((d) => d !== date)
|
||||
: [...habit.completedDates, date];
|
||||
return { ...habit, completedDates };
|
||||
}
|
||||
return habit;
|
||||
});
|
||||
onUpdateHabits(updatedHabits);
|
||||
};
|
||||
|
||||
const getIntensity = (habit, date) => {
|
||||
return habit.completedDates.includes(date) ? 1 : 0;
|
||||
};
|
||||
|
||||
const getIntensityColor = (intensity) => {
|
||||
if (intensity === 0) return "bg-gray-100 dark:bg-gray-800";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
const nextHabit = () => {
|
||||
if (habits.length === 0) return;
|
||||
|
||||
if (currentHabitIndex === -1) {
|
||||
// From overview, go to first habit
|
||||
setCurrentHabitIndex(0);
|
||||
} else if (currentHabitIndex === habits.length - 1) {
|
||||
// From last habit, go back to overview
|
||||
setCurrentHabitIndex(-1);
|
||||
} else {
|
||||
// Go to next habit
|
||||
setCurrentHabitIndex(currentHabitIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevHabit = () => {
|
||||
if (habits.length === 0) return;
|
||||
|
||||
if (currentHabitIndex === -1) {
|
||||
// From overview, go to last habit
|
||||
setCurrentHabitIndex(habits.length - 1);
|
||||
} else if (currentHabitIndex === 0) {
|
||||
// From first habit, go back to overview
|
||||
setCurrentHabitIndex(-1);
|
||||
} else {
|
||||
// Go to previous habit
|
||||
setCurrentHabitIndex(currentHabitIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const currentHabit =
|
||||
currentHabitIndex >= 0 ? habits[currentHabitIndex] : null;
|
||||
const currentHabitTag = customTags.find(
|
||||
(tag) => tag.id === currentHabit?.tag
|
||||
);
|
||||
|
||||
// Calculate total habits completed in the past 30 days
|
||||
const totalCompletions = pastDays.reduce(
|
||||
(total, date) => total + getDailyHabitCount(date),
|
||||
0
|
||||
);
|
||||
|
||||
// Animation variants
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: {
|
||||
y: "100%",
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
y: "100%",
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeIn",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const contentVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.3, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
const formVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeIn",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const colorButtonVariants = {
|
||||
hidden: { scale: 0, opacity: 0 },
|
||||
visible: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
scale: 1.1,
|
||||
transition: { duration: 0.1 },
|
||||
},
|
||||
tap: { scale: 0.95 },
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-end justify-center z-50"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalVariants}
|
||||
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg max-h-[90vh] overflow-hidden shadow-2xl border-t border-gray-200 dark:border-gray-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<motion.div
|
||||
className="flex justify-center pt-4 pb-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-1.5 bg-gray-400 dark:bg-gray-500 rounded-full cursor-pointer"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto max-h-[calc(90vh-70px)]">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="flex items-center justify-between mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ rotate: -10, scale: 0.8 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{
|
||||
delay: 0.25,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
}}
|
||||
className="p-2.5 bg-primary/10 rounded-xl"
|
||||
>
|
||||
<RotateCcw className="h-5 w-5 text-primary" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-extrabold text-gray-900 dark:text-gray-100 tracking-wide">
|
||||
Habit Tracker
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl p-2"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Add Habit Form */}
|
||||
<AnimatePresence>
|
||||
{showAddForm ? (
|
||||
<motion.div
|
||||
variants={formVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4 rounded-xl"
|
||||
>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Input
|
||||
placeholder="What habit do you want to build?"
|
||||
value={newHabitName}
|
||||
onChange={(e) => setNewHabitName(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && !showAddTag && addHabit()
|
||||
}
|
||||
autoFocus
|
||||
className="border-0 bg-transparent md:text-2xl h-10 font-extrabold px-0 py-2 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Tag Selection */}
|
||||
<motion.div variants={itemVariants} className="space-y-3">
|
||||
<Select value={selectedTag} onValueChange={setSelectedTag}>
|
||||
<SelectTrigger className="border-2 border-gray-300 focus:border-primary/70 font-extrabold dark:border-gray-600 dark:focus:border-primary/80 dark:bg-gray-800 dark:text-gray-100 rounded-xl py-3">
|
||||
<SelectValue placeholder="Choose a category (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
{customTags.map((tag) => (
|
||||
<SelectItem
|
||||
key={tag.id}
|
||||
value={tag.id}
|
||||
className="rounded-lg dark:hover:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
whileHover={{ scale: 1.2 }}
|
||||
/>
|
||||
<span className="font-extrabold">{tag.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAddTag(!showAddTag)}
|
||||
className="w-full border-2 border-gray-300 font-extrabold hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100 rounded-xl py-3"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: showAddTag ? 45 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</motion.div>
|
||||
{showAddTag ? "Cancel" : "Create New Category"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Add New Tag Form */}
|
||||
<AnimatePresence>
|
||||
{showAddTag && (
|
||||
<motion.div
|
||||
variants={formVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4 p-4 border-2 border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700"
|
||||
>
|
||||
<Input
|
||||
placeholder="Category name"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addTag()}
|
||||
className="border-2 border-gray-300 font-extrabold focus:border-primary/70 dark:border-gray-600 dark:focus:border-primary/80 dark:text-gray-100 rounded-xl bg-white dark:bg-gray-700 py-3"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-extrabold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
||||
Choose Color
|
||||
</label>
|
||||
<motion.div
|
||||
className="flex gap-3 flex-wrap justify-center"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.04,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{PRESET_COLORS.map((color, index) => (
|
||||
<motion.button
|
||||
key={color}
|
||||
variants={colorButtonVariants}
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={`w-10 h-10 rounded-full border-3 transition-all duration-200 relative overflow-hidden ${
|
||||
selectedColor === color
|
||||
? "border-gray-900 dark:border-gray-100 shadow-lg ring-2 ring-primary/50"
|
||||
: "border-gray-200 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-400"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
custom={index}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-full h-full rounded-full flex items-center justify-center bg-black/20 dark:bg-white/20 backdrop-blur-sm"
|
||||
>
|
||||
<Check className="h-4 w-4 text-white drop-shadow-sm" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
onClick={addTag}
|
||||
className="w-full rounded-xl font-extrabold py-3"
|
||||
disabled={!newTagName.trim()}
|
||||
>
|
||||
Create Category
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<motion.div
|
||||
className="flex-1"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
onClick={addHabit}
|
||||
className="w-full rounded-xl font-extrabold py-4 text-lg shadow-lg"
|
||||
disabled={!newHabitName.trim()}
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Habit
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewHabitName("");
|
||||
setSelectedTag("");
|
||||
setShowAddTag(false);
|
||||
setNewTagName("");
|
||||
setShowNavigation(true);
|
||||
}}
|
||||
className="px-6 py-4 rounded-xl font-extrabold border-2 border-gray-300 hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddForm(true);
|
||||
setShowNavigation(false);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full border-2 border-gray-300 font-extrabold hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100 rounded-xl py-4"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add New Habit
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Habit Navigation */}
|
||||
{showNavigation && habits.length > 0 && (
|
||||
<motion.div variants={itemVariants} className="space-y-6">
|
||||
{/* Habit Tabs */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/80 rounded-xl border-2 border-gray-200 dark:border-gray-700">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={prevHabit}
|
||||
disabled={habits.length === 0}
|
||||
className="p-2 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 text-center">
|
||||
{currentHabitIndex === -1 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-extrabold text-gray-900 dark:text-gray-100">
|
||||
All Habits Overview
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-medium">
|
||||
{totalCompletions} total completions • {habits.length}{" "}
|
||||
habits
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-extrabold text-gray-900 dark:text-gray-100 truncate px-2">
|
||||
{currentHabit?.name}
|
||||
</h3>
|
||||
{currentHabitTag && (
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: currentHabitTag.color }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{currentHabitTag.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={nextHabit}
|
||||
disabled={habits.length === 0}
|
||||
className="p-2 rounded-lg"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Habit Dots Indicator */}
|
||||
<div className="flex justify-center gap-1">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.8 }}
|
||||
onClick={() => setCurrentHabitIndex(-1)}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-200 ${
|
||||
currentHabitIndex === -1
|
||||
? "bg-primary shadow-lg"
|
||||
: "bg-primary/30"
|
||||
}`}
|
||||
/>
|
||||
{habits.map((_, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.8 }}
|
||||
onClick={() => setCurrentHabitIndex(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-200 ${
|
||||
index === currentHabitIndex
|
||||
? "bg-primary shadow-lg"
|
||||
: "bg-primary/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current View Display */}
|
||||
<AnimatePresence mode="wait">
|
||||
{currentHabitIndex === -1 ? (
|
||||
// Overview of all habits
|
||||
<motion.div
|
||||
key="overview"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4 justify-center items-center bg-gray-50 dark:bg-gray-800/80 rounded-xl border-2 border-gray-200 dark:border-gray-700 h-[210px]">
|
||||
<div className="flex flex-row-reverse items-center w-full relative">
|
||||
<span className="text-sm font-extrabold text-gray-700 dark:text-gray-200 uppercase tracking-wider absolute left-1/2 transform -translate-x-1/2">
|
||||
Past 30 days
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-extrabold h-5">
|
||||
{Math.round(
|
||||
(totalCompletions / (habits.length * 30)) * 100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 w-fit gap-2">
|
||||
{pastDays.map((date) => {
|
||||
const count = getDailyHabitCount(date);
|
||||
return (
|
||||
<motion.div
|
||||
key={date}
|
||||
whileHover={{ scale: 1.3 }}
|
||||
className={`w-4 h-4 rounded-md transition-all duration-200 cursor-pointer ${
|
||||
count === 0
|
||||
? "bg-primary/10"
|
||||
: count === 1
|
||||
? "bg-primary/30"
|
||||
: count === 2
|
||||
? "bg-primary/50"
|
||||
: count === 3
|
||||
? "bg-primary/70"
|
||||
: count === 4
|
||||
? "bg-primary/85"
|
||||
: "bg-primary"
|
||||
}`}
|
||||
title={`${new Date(
|
||||
date
|
||||
).toLocaleDateString()} - ${count} habit${
|
||||
count !== 1 ? "s" : ""
|
||||
} completed`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{totalCompletions} total habit completions this month
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
// Individual habit view
|
||||
currentHabit && (
|
||||
<motion.div
|
||||
key={currentHabit.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* GitHub-style grid */}
|
||||
<div className="flex flex-col gap-4 p-4 justify-center items-center bg-gray-50 dark:bg-gray-800/80 rounded-xl border-2 border-gray-200 dark:border-gray-700 h-[210px]">
|
||||
<div className="flex flex-row-reverse items-center w-full relative">
|
||||
<span className="text-sm font-extrabold text-gray-700 dark:text-gray-200 uppercase tracking-wider absolute left-1/2 transform -translate-x-1/2">
|
||||
Past 30 days
|
||||
</span>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="h-5"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteHabit(currentHabit.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs hover:bg-transparent font-extrabold rounded-lg p-0 h-5"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 w-fit gap-2 -mt-1">
|
||||
{pastDays.map((date) => (
|
||||
<motion.button
|
||||
key={date}
|
||||
whileHover={{ scale: 1.3 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() =>
|
||||
toggleHabitDay(currentHabit.id, date)
|
||||
}
|
||||
className={`w-4 h-4 rounded-md transition-all duration-200 ${
|
||||
getIntensity(currentHabit, date) === 0
|
||||
? "bg-primary/10 hover:bg-primary/20"
|
||||
: "bg-primary hover:bg-primary/90"
|
||||
} hover:ring-2 hover:ring-primary/50`}
|
||||
title={`${currentHabit.name} - ${new Date(
|
||||
date
|
||||
).toLocaleDateString()}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{currentHabit.completedDates.length} days completed
|
||||
this month
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{habits.length === 0 && !showAddForm && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="text-center py-12 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring" }}
|
||||
className="p-4 bg-gray-50 dark:bg-gray-800/80 rounded-xl border-2 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<RotateCcw className="h-12 w-12 mx-auto mb-4 text-gray-400 dark:text-gray-500" />
|
||||
<p className="font-extrabold text-lg">No habits yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
Add one to start tracking your progress!
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="pt-4 border-t-2 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-4 h-4 rounded-md bg-primary/10"></div>
|
||||
<div className="w-4 h-4 rounded-md bg-primary/30"></div>
|
||||
<div className="w-4 h-4 rounded-md bg-primary/50"></div>
|
||||
<div className="w-4 h-4 rounded-md bg-primary/70"></div>
|
||||
<div className="w-4 h-4 rounded-md bg-primary/85"></div>
|
||||
<div className="w-4 h-4 rounded-md bg-primary"></div>
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-400 dark:text-gray-500 mt-2 font-medium">
|
||||
{currentHabitIndex === -1
|
||||
? "Daily habit completions (1-5+)"
|
||||
: "Individual habit completion"}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+13
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Calendar, Check, Clock, RefreshCcw } from "lucide-react";
|
||||
import { Calendar, Check, Clock, RotateCcw } from "lucide-react";
|
||||
import { formatFocusTime } from "@/utils/time"; // Assuming this utility exists
|
||||
|
||||
export function TaskList({
|
||||
@@ -151,10 +151,10 @@ export function TaskList({
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" /> Habits
|
||||
<RotateCcw className="h-4 w-4" /> Habits
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
className=""
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -172,6 +172,7 @@ export function TaskList({
|
||||
getTagInfo={getTagInfo}
|
||||
isHabit={true}
|
||||
variants={taskVariants}
|
||||
isLastTask={task.id === habitTasks[habitTasks.length - 1].id}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -198,7 +199,7 @@ export function TaskList({
|
||||
<Calendar className="h-4 w-4" /> Tasks
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
className=""
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -216,6 +217,9 @@ export function TaskList({
|
||||
getTagInfo={getTagInfo}
|
||||
isHabit={false}
|
||||
variants={taskVariants}
|
||||
isLastTask={
|
||||
task.id === regularTasks[regularTasks.length - 1].id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -249,6 +253,7 @@ function TaskItem({
|
||||
getTagInfo,
|
||||
isHabit,
|
||||
variants,
|
||||
isLastTask,
|
||||
}) {
|
||||
const tagInfo = getTagInfo(task.tag);
|
||||
|
||||
@@ -256,13 +261,12 @@ function TaskItem({
|
||||
<motion.div
|
||||
variants={variants}
|
||||
layout
|
||||
className={`relative p-4 border-y border-dashed cursor-pointer select-none overflow-hidden ${
|
||||
className={`relative p-4 border-t border-dashed cursor-pointer select-none overflow-hidden ${
|
||||
task.completed
|
||||
? ""
|
||||
: isHabit
|
||||
? " dark:bg-gray-800 border-primary/50 dark:border-gray-700 hover:bg-primary/5"
|
||||
: " dark:bg-gray-800 border-primary/50 dark:border-gray-700 hover:bg-primary/5"
|
||||
}`}
|
||||
: "border-primary/50 dark:border-primary-700 hover:bg-primary/5"
|
||||
}
|
||||
${isLastTask ? "border-b" : ""}`}
|
||||
onMouseDown={(e) => onMouseDown(task.id, e)}
|
||||
onTouchStart={(e) => onMouseDown(task.id, e)}
|
||||
onClick={(e) => onTaskClick(task, e)}
|
||||
|
||||
@@ -31,6 +31,8 @@ const PRESET_COLORS = [
|
||||
"#8b5cf6", // purple
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
"#a85520", // brown
|
||||
"#6366f1", // indigo
|
||||
];
|
||||
|
||||
export function TaskOptionsModal({
|
||||
@@ -286,7 +288,9 @@ export function TaskOptionsModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-4 px-0 h-10 rounded-xl ">
|
||||
<span className="font-extrabold text-2xl">{task.title}</span>
|
||||
<span className="font-extrabold text-2xl">
|
||||
{editTitle || task.title}
|
||||
</span>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
X,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Coffee,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Minus,
|
||||
Timer,
|
||||
Briefcase,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CountdownTimer } from "@/components/countdown-timer";
|
||||
|
||||
export function TimerModal({
|
||||
tasks,
|
||||
onClose,
|
||||
onUpdateTaskTime,
|
||||
onUpdateTaskFocusTime,
|
||||
onToggleTask,
|
||||
}) {
|
||||
const [selectedTask, setSelectedTask] = useState("");
|
||||
const [timeLeft, setTimeLeft] = useState(25 * 60); // 25 minutes in seconds
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [preset, setPreset] = useState("25");
|
||||
const [workPreset, setWorkPreset] = useState("25"); // Store the work preset separately
|
||||
const [isBreak, setIsBreak] = useState(false);
|
||||
const [sessionStartTime, setSessionStartTime] = useState(0);
|
||||
const [lastFocusUpdate, setLastFocusUpdate] = useState(Date.now());
|
||||
const [overtimeSeconds, setOvertimeSeconds] = useState(0); // Track overtime
|
||||
|
||||
const presets = [
|
||||
{ value: "5", label: "5 min", seconds: 5 * 60 },
|
||||
{ value: "10", label: "10 min", seconds: 10 * 60 },
|
||||
{ value: "25", label: "25 min", seconds: 25 * 60 },
|
||||
{ value: "50", label: "50 min", seconds: 50 * 60 },
|
||||
];
|
||||
|
||||
// Corrected Code
|
||||
|
||||
// Main countdown timer effect
|
||||
useEffect(() => {
|
||||
// This interval only runs when the timer should be active AND has time remaining.
|
||||
if (isRunning && timeLeft > 0) {
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
// The cleanup function clears the interval when the component re-renders
|
||||
// or when the dependencies change.
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isRunning, timeLeft]); // Add timeLeft as a dependency
|
||||
|
||||
// Overtime counter effect
|
||||
useEffect(() => {
|
||||
// This interval ONLY runs when the timer is active but has run out of time.
|
||||
if (isRunning && timeLeft === 0) {
|
||||
const overtimeInterval = setInterval(() => {
|
||||
setOvertimeSeconds((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// The cleanup function for the overtime interval.
|
||||
return () => clearInterval(overtimeInterval);
|
||||
}
|
||||
}, [isRunning, timeLeft]); // Also depends on isRunning and timeLeft
|
||||
|
||||
// Focus time tracking effect - updates every 10 seconds
|
||||
useEffect(() => {
|
||||
let focusInterval;
|
||||
if (isRunning && !isBreak && selectedTask) {
|
||||
focusInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeDiff = Math.floor((now - lastFocusUpdate) / 1000);
|
||||
if (timeDiff >= 10) {
|
||||
onUpdateTaskFocusTime(selectedTask, 10);
|
||||
setLastFocusUpdate(now);
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
}
|
||||
return () => clearInterval(focusInterval);
|
||||
}, [
|
||||
isRunning,
|
||||
isBreak,
|
||||
selectedTask,
|
||||
onUpdateTaskFocusTime,
|
||||
lastFocusUpdate,
|
||||
]);
|
||||
|
||||
// Reset focus tracking when starting/stopping
|
||||
useEffect(() => {
|
||||
if (isRunning && !isBreak && selectedTask) {
|
||||
setLastFocusUpdate(Date.now());
|
||||
}
|
||||
}, [isRunning, isBreak, selectedTask]);
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handlePresetChange = (value) => {
|
||||
setPreset(value);
|
||||
setWorkPreset(value); // Save the work preset when user changes it
|
||||
const presetData = presets.find((p) => p.value === value);
|
||||
if (presetData) {
|
||||
setTimeLeft(presetData.seconds);
|
||||
setIsRunning(false);
|
||||
setSessionStartTime(presetData.seconds);
|
||||
setOvertimeSeconds(0); // Reset overtime
|
||||
}
|
||||
};
|
||||
|
||||
const adjustTime = (minutes) => {
|
||||
const newTime = Math.max(60, timeLeft + minutes * 60); // Cap at 60 seconds (1 minute)
|
||||
setTimeLeft(newTime);
|
||||
// Update session start time if we're adjusting while running
|
||||
if (isRunning) {
|
||||
setSessionStartTime((prev) => prev + minutes * 60);
|
||||
} else {
|
||||
setSessionStartTime(newTime);
|
||||
}
|
||||
// Reset overtime when adjusting time
|
||||
if (newTime > 0) {
|
||||
setOvertimeSeconds(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!isRunning && timeLeft > 0) {
|
||||
setSessionStartTime(timeLeft);
|
||||
setOvertimeSeconds(0); // Reset overtime when starting fresh
|
||||
}
|
||||
setIsRunning(!isRunning);
|
||||
};
|
||||
|
||||
const handleFinishTask = () => {
|
||||
if (selectedTask && !isBreak) {
|
||||
// Calculate total time spent including overtime
|
||||
const baseTimeSpent = Math.ceil((sessionStartTime - timeLeft) / 60);
|
||||
const overtimeMinutes = Math.ceil(overtimeSeconds / 60);
|
||||
const totalTimeSpent = baseTimeSpent + overtimeMinutes;
|
||||
|
||||
// Update task time if any time was spent
|
||||
if (totalTimeSpent > 0) {
|
||||
onUpdateTaskTime(selectedTask, totalTimeSpent);
|
||||
}
|
||||
|
||||
// Add any remaining focus time (less than 10 seconds)
|
||||
if (isRunning) {
|
||||
const now = Date.now();
|
||||
const remainingFocusTime = Math.floor((now - lastFocusUpdate) / 1000);
|
||||
if (remainingFocusTime > 0) {
|
||||
onUpdateTaskFocusTime(selectedTask, remainingFocusTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the task as complete
|
||||
onToggleTask(selectedTask);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBreak = () => {
|
||||
setIsBreak(true);
|
||||
setTimeLeft(5 * 60); // 5 minute break
|
||||
setSessionStartTime(5 * 60);
|
||||
setOvertimeSeconds(0); // Reset overtime
|
||||
setIsRunning(true); // Auto-start break timer
|
||||
setPreset("5");
|
||||
};
|
||||
|
||||
const handleBackToWork = () => {
|
||||
setIsBreak(false);
|
||||
const selectedPreset =
|
||||
presets.find((p) => p.value === workPreset) || presets[2];
|
||||
setTimeLeft(selectedPreset.seconds);
|
||||
setSessionStartTime(selectedPreset.seconds);
|
||||
setOvertimeSeconds(0); // Reset overtime
|
||||
setIsRunning(true); // Auto-start work timer
|
||||
setPreset(selectedPreset.value);
|
||||
};
|
||||
|
||||
const handleAbandon = () => {
|
||||
setIsRunning(false);
|
||||
setOvertimeSeconds(0); // Reset overtime
|
||||
const presetData = presets.find((p) => p.value === preset);
|
||||
if (presetData) {
|
||||
setTimeLeft(presetData.seconds);
|
||||
setSessionStartTime(presetData.seconds);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to show all incomplete tasks and habits
|
||||
const incompleteItems = tasks.filter((item) => !item.completed);
|
||||
|
||||
// Animation variants
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: {
|
||||
y: "100%",
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
y: "100%",
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeIn",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const contentVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
delayChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.3, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-end justify-center z-50"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalVariants}
|
||||
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg max-h-[90vh] overflow-hidden shadow-2xl border-t border-gray-200 dark:border-gray-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<motion.div
|
||||
className="flex justify-center pt-4 pb-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-1.5 bg-gray-400 dark:bg-gray-500 rounded-full cursor-pointer"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="px-6 pb-6 overflow-y-auto max-h-[calc(90vh-70px)]">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="flex items-center justify-between mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ rotate: -10, scale: 0.8 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{
|
||||
delay: 0.25,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
}}
|
||||
className="p-2.5 bg-primary/10 rounded-xl"
|
||||
>
|
||||
<Timer className="h-5 w-5 text-primary" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-extrabold text-gray-900 dark:text-gray-100 tracking-wide">
|
||||
{isBreak ? "Break Time" : "Focus Timer"}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl p-2"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Task Selection - Only show during work sessions */}
|
||||
{!isBreak && (
|
||||
<motion.div variants={itemVariants} className="space-y-3">
|
||||
<label className="text-sm font-extrabold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
||||
{!isRunning
|
||||
? "Focus on task"
|
||||
: `Focusing on ${
|
||||
incompleteItems.find((item) => item.id === selectedTask)
|
||||
?.title
|
||||
}`}
|
||||
</label>
|
||||
{!isRunning &&
|
||||
(incompleteItems.length > 0 ? (
|
||||
<Select
|
||||
value={selectedTask}
|
||||
onValueChange={setSelectedTask}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<SelectTrigger className="border-2 border-gray-300 focus:border-primary/70 font-extrabold dark:border-gray-600 dark:focus:border-primary/80 dark:bg-gray-800 dark:text-gray-100 rounded-xl py-3">
|
||||
<SelectValue placeholder="Select a task or habit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
{incompleteItems.map((item) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
className="rounded-lg dark:hover:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-extrabold">
|
||||
{item.title}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="p-6 bg-gray-50 dark:bg-gray-800/80 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 3,
|
||||
}}
|
||||
className="text-4xl mb-3"
|
||||
>
|
||||
🎉
|
||||
</motion.div>
|
||||
<p className="font-extrabold text-lg text-gray-900 dark:text-gray-100">
|
||||
You are done for today!
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Great job completing all your tasks and habits.
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{incompleteItems.length > 0 &&
|
||||
(isBreak || selectedTask) &&
|
||||
!isBreak &&
|
||||
isRunning && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleFinishTask}
|
||||
disabled={!selectedTask}
|
||||
className={`w-full rounded-xl font-extrabold py-4 text-lg shadow-lg bg-primary hover:bg-primary/70 text-white`}
|
||||
variant="default"
|
||||
>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Show timer sections only if there are tasks to complete OR during break OR task is selected */}
|
||||
{incompleteItems.length > 0 && (isBreak || selectedTask) && (
|
||||
<>
|
||||
{/* Timer Display */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<div className="relative inline-block">
|
||||
<div className="flex items-center justify-center">
|
||||
{/* Minus Button */}
|
||||
<motion.div
|
||||
className="mr-6"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => adjustTime(-5)}
|
||||
className="border-2 border-gray-300 dark:border-gray-600 hover:border-primary/70 dark:hover:border-primary/80 rounded-xl font-extrabold w-12 h-12 p-0"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="mx-4">
|
||||
{timeLeft === 0 ? (
|
||||
// Show overtime counter
|
||||
<>
|
||||
<motion.div
|
||||
className="text-7xl font-extrabold text-red-500 mb-2"
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
>
|
||||
+{formatTime(overtimeSeconds)}
|
||||
</motion.div>
|
||||
<div className="text-lg text-red-400 uppercase tracking-wider font-bold">
|
||||
Overtime
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Show regular countdown
|
||||
<CountdownTimer
|
||||
value={timeLeft}
|
||||
fontSize={84}
|
||||
textColor={
|
||||
isBreak ? "#10b981" : "hsl(var(--primary))"
|
||||
}
|
||||
fontWeight="800"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plus Button */}
|
||||
<motion.div
|
||||
className="ml-6"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => adjustTime(5)}
|
||||
className="border-2 border-gray-300 dark:border-gray-600 hover:border-primary/70 dark:hover:border-primary/80 rounded-xl font-extrabold w-12 h-12 p-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Status */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<div
|
||||
className={`inline-flex items-center px-4 py-2 rounded-xl text-sm font-extrabold uppercase tracking-wider shadow-lg ${
|
||||
timeLeft === 0
|
||||
? "bg-primary/10 text-primary border-2 border-primary/30"
|
||||
: isBreak
|
||||
? "bg-primary/10 text-primary border-2 border-primary/30"
|
||||
: "bg-primary/10 text-primary border-2 border-primary/30"
|
||||
}`}
|
||||
>
|
||||
{timeLeft === 0
|
||||
? "⏰ Overtime Mode"
|
||||
: isBreak
|
||||
? "🧘 Break Time"
|
||||
: "🎯 Focus Time"}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Preset Selection - Only show during work sessions */}
|
||||
{!isBreak && (
|
||||
<motion.div variants={itemVariants} className="space-y-3">
|
||||
<label className="text-sm font-extrabold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
||||
Quick Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{presets.map((p) => (
|
||||
<motion.div
|
||||
key={p.value}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Button
|
||||
variant={preset === p.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePresetChange(p.value)}
|
||||
className={`w-full font-extrabold rounded-xl py-3 ${
|
||||
preset === p.value
|
||||
? "shadow-lg"
|
||||
: "border-2 border-gray-300 dark:border-gray-600 hover:border-primary/70 dark:hover:border-primary/80"
|
||||
}`}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{p.value}
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Control Buttons - Music Player Style */}
|
||||
<motion.div variants={itemVariants} className="space-y-4">
|
||||
{/* Music Player Controls */}
|
||||
<div className="flex justify-center items-center gap-8">
|
||||
{/* Stop/Reset Button */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAbandon}
|
||||
className="w-14 h-14 p-0 rounded-full font-extrabold border-2 border-gray-300 hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100"
|
||||
>
|
||||
<Square className="h-6 w-6" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Play/Pause Button */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
className="w-16 h-16 p-0 rounded-full font-extrabold text-lg shadow-lg"
|
||||
disabled={!isBreak && !selectedTask && timeLeft > 0}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Pause className="h-7 w-7" />
|
||||
) : (
|
||||
<Play className="h-7 w-7" />
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Break Button */}
|
||||
{!isBreak && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBreak}
|
||||
className="w-14 h-14 p-0 rounded-full font-extrabold border-2 border-gray-300 hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100"
|
||||
>
|
||||
<Coffee className="h-6 w-6" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Back to Work Button (during break) */}
|
||||
{isBreak && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBackToWork}
|
||||
className="w-14 h-14 p-0 rounded-full border-2 border-gray-300 hover:border-primary/70 dark:border-gray-600 dark:hover:border-primary/80 dark:text-gray-100"
|
||||
>
|
||||
<Briefcase className="h-6 w-6" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user