mirror of
https://github.com/CarGDev/pomodoro.git
synced 2025-09-18 17:38:28 +00:00
feat: implement comprehensive task management system with SCSS modules
- Add complete task management functionality with atomic design - Create TaskCard component with checkbox and delete functionality - Implement TaskForm with drawer interface for task editing - Add Tasks organism with right-side drawer and completion states - Move TodayProgress and QuickActions to Sidebar for better UX - Implement SCSS modules for all components with proper styling - Add task statistics to progress tracking - Update store with task management actions and persistence - Improve layout with 30/70 column split for tasks and timer - Add SASS dependency for SCSS compilation - Ensure completed tasks are non-editable with proper visual states
This commit is contained in:
21
client/src/components/atoms/AddTaskButton.tsx
Normal file
21
client/src/components/atoms/AddTaskButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Button } from "@/components/atoms/Button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface AddTaskButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddTaskButton({ onClick, className }: AddTaskButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
99
client/src/components/atoms/TaskCard.tsx
Normal file
99
client/src/components/atoms/TaskCard.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Task } from "@/lib/store";
|
||||||
|
import styles from "@/styles/TaskCard.module.scss";
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
onClick: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onToggleComplete: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCard({ task, onClick, onDelete, onToggleComplete, className }: TaskCardProps) {
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
return `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (checked: boolean) => {
|
||||||
|
onToggleComplete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
// Card is always clickable, regardless of completion status
|
||||||
|
onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
styles.card,
|
||||||
|
task.completed && styles.completed,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
>
|
||||||
|
<CardContent className={styles.cardContent}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Checkbox
|
||||||
|
checked={task.completed}
|
||||||
|
onCheckedChange={handleCheckboxClick}
|
||||||
|
className={styles.checkbox}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className={styles.mainContent}>
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
styles.title,
|
||||||
|
task.completed && styles.completed
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
{task.description && (
|
||||||
|
<p className={styles.description}>
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.metaInfo}>
|
||||||
|
{task.eta > 0 && (
|
||||||
|
<span className={styles.metaItem}>
|
||||||
|
ETA: {formatDuration(task.eta)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.duration > 0 && (
|
||||||
|
<span className={styles.metaItem}>
|
||||||
|
Spent: {formatDuration(task.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{!task.completed && (
|
||||||
|
<button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
title="Delete task"
|
||||||
|
>
|
||||||
|
<Trash2 className={styles.deleteIcon} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
123
client/src/components/molecules/TaskForm.tsx
Normal file
123
client/src/components/molecules/TaskForm.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/atoms/Button";
|
||||||
|
import type { Task } from "@/lib/store";
|
||||||
|
import styles from "@/styles/TaskForm.module.scss";
|
||||||
|
|
||||||
|
interface TaskFormProps {
|
||||||
|
task?: Task;
|
||||||
|
onSubmit: (task: Omit<Task, 'id' | 'createdAt'>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskForm({ task, onSubmit, onCancel, className }: TaskFormProps) {
|
||||||
|
const [title, setTitle] = useState(task?.title || "");
|
||||||
|
const [description, setDescription] = useState(task?.description || "");
|
||||||
|
const [notes, setNotes] = useState(task?.notes || "");
|
||||||
|
const [eta, setEta] = useState(task?.eta || 0);
|
||||||
|
const [duration, setDuration] = useState(task?.duration || 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task) {
|
||||||
|
setTitle(task.title);
|
||||||
|
setDescription(task.description);
|
||||||
|
setNotes(task.notes);
|
||||||
|
setEta(task.eta);
|
||||||
|
setDuration(task.duration);
|
||||||
|
}
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
notes: notes.trim(),
|
||||||
|
eta,
|
||||||
|
duration,
|
||||||
|
completed: task?.completed || false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className={className}>
|
||||||
|
<div className={styles.formContent}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Label htmlFor="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter task title"
|
||||||
|
required
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of the task"
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={3}
|
||||||
|
className={styles.textarea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Label htmlFor="eta">ETA (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="eta"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={eta || ""}
|
||||||
|
onChange={(e) => setEta(parseInt(e.target.value) || 0)}
|
||||||
|
placeholder=""
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Label htmlFor="duration">Duration (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="duration"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={duration || ""}
|
||||||
|
onChange={(e) => setDuration(parseInt(e.target.value) || 0)}
|
||||||
|
placeholder=""
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<Button type="submit" className={styles.submitButton}>
|
||||||
|
{task ? "Update Task" : "Create Task"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
60
client/src/components/organisms/QuickActions.tsx
Normal file
60
client/src/components/organisms/QuickActions.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Download, Volume2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/atoms/Button";
|
||||||
|
import { useStore } from "@/lib/store";
|
||||||
|
import styles from "@/styles/QuickActions.module.scss";
|
||||||
|
|
||||||
|
const QuicksActions = () => {
|
||||||
|
const localSessions = useStore((state) => state.localSessions);
|
||||||
|
|
||||||
|
const handleExportData = () => {
|
||||||
|
const dataStr = JSON.stringify(localSessions, null, 2);
|
||||||
|
const dataUri =
|
||||||
|
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||||
|
|
||||||
|
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split("T")[0]}.json`;
|
||||||
|
|
||||||
|
const linkElement = document.createElement("a");
|
||||||
|
linkElement.setAttribute("href", dataUri);
|
||||||
|
linkElement.setAttribute("download", exportFileDefaultName);
|
||||||
|
linkElement.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={styles.cardContent}>
|
||||||
|
{/* <Button */}
|
||||||
|
{/* variant="outline" */}
|
||||||
|
{/* className={`${styles.actionButton} ${styles.soundButton}`} */}
|
||||||
|
{/* data-testid="button-sound-settings" */}
|
||||||
|
{/* > */}
|
||||||
|
{/* <Volume2 className={styles.soundIcon} /> */}
|
||||||
|
{/* <div className={styles.buttonContent}> */}
|
||||||
|
{/* <div className={styles.buttonDescription}> */}
|
||||||
|
{/* Adjust notification sounds */}
|
||||||
|
{/* </div> */}
|
||||||
|
{/* </div> */}
|
||||||
|
{/* </Button> */}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`${styles.actionButton} ${styles.exportButton}`}
|
||||||
|
onClick={handleExportData}
|
||||||
|
data-testid="button-export-data"
|
||||||
|
>
|
||||||
|
<Download className={styles.exportIcon} />
|
||||||
|
<div className={styles.buttonContent}>
|
||||||
|
<div className={styles.buttonDescription}>
|
||||||
|
Download session history
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuicksActions;
|
@@ -3,6 +3,8 @@ import { Button } from "@/components/atoms/Button";
|
|||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { useStore } from "@/lib/store";
|
import { useStore } from "@/lib/store";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
|
import TodayProgress from "@/components/organisms/todayProgress";
|
||||||
|
import QuickActions from "@/components/organisms/QuickActions";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -17,24 +19,6 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const localSessions = useStore((state) => state.localSessions);
|
|
||||||
|
|
||||||
// Calculate today's stats
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const todaySessions = localSessions.filter((session) => {
|
|
||||||
const sessionDate = new Date(session.startedAt);
|
|
||||||
sessionDate.setHours(0, 0, 0, 0);
|
|
||||||
return sessionDate.getTime() === today.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
|
||||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
|
||||||
const completionRate =
|
|
||||||
todayFocusSessions.length > 0
|
|
||||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/", icon: Clock, label: "Timer", testId: "nav-timer" },
|
{ path: "/", icon: Clock, label: "Timer", testId: "nav-timer" },
|
||||||
@@ -103,24 +87,11 @@ export function Sidebar({
|
|||||||
|
|
||||||
{/* Theme Toggle & Stats */}
|
{/* Theme Toggle & Stats */}
|
||||||
<div className="p-4 border-t border-border space-y-4">
|
<div className="p-4 border-t border-border space-y-4">
|
||||||
{/* Quick Stats */}
|
{/* Today's Progress */}
|
||||||
<div className="bg-muted rounded-lg p-3 text-sm">
|
<TodayProgress />
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<span className="text-muted-foreground">Today's Sessions</span>
|
{/* Quick Actions */}
|
||||||
<span className="font-medium" data-testid="stat-today-sessions">
|
<QuickActions />
|
||||||
{todayFocusSessions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-muted-foreground">Completion Rate</span>
|
|
||||||
<span
|
|
||||||
className="font-medium text-chart-2"
|
|
||||||
data-testid="stat-today-completion"
|
|
||||||
>
|
|
||||||
{completionRate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<Button
|
<Button
|
||||||
|
139
client/src/components/organisms/Tasks.tsx
Normal file
139
client/src/components/organisms/Tasks.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { TaskCard } from "@/components/atoms/TaskCard";
|
||||||
|
import { AddTaskButton } from "@/components/atoms/AddTaskButton";
|
||||||
|
import { TaskForm } from "@/components/molecules/TaskForm";
|
||||||
|
import { useStore } from "@/lib/store";
|
||||||
|
import type { Task } from "@/lib/store";
|
||||||
|
import styles from "@/styles/Tasks.module.scss";
|
||||||
|
|
||||||
|
export function Tasks() {
|
||||||
|
const tasks = useStore((state) => state.tasks);
|
||||||
|
const addTask = useStore((state) => state.addTask);
|
||||||
|
const updateTask = useStore((state) => state.updateTask);
|
||||||
|
const deleteTask = useStore((state) => state.deleteTask);
|
||||||
|
const completeTask = useStore((state) => state.completeTask);
|
||||||
|
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [editingTask, setEditingTask] = useState<Task | undefined>(undefined);
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
setEditingTask(undefined);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTask = (task: Task) => {
|
||||||
|
// Only allow editing if task is not completed
|
||||||
|
if (!task.completed) {
|
||||||
|
setEditingTask(task);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitTask = (taskData: Omit<Task, 'id' | 'createdAt'>) => {
|
||||||
|
if (editingTask) {
|
||||||
|
updateTask(editingTask.id, taskData);
|
||||||
|
} else {
|
||||||
|
addTask(taskData);
|
||||||
|
}
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setEditingTask(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setEditingTask(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTask = (taskId: string) => {
|
||||||
|
deleteTask(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = (taskId: string) => {
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
if (task.completed) {
|
||||||
|
updateTask(taskId, { completed: false, completedAt: undefined });
|
||||||
|
} else {
|
||||||
|
completeTask(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const incompleteTasks = tasks.filter((task) => !task.completed);
|
||||||
|
const completedTasks = tasks.filter((task) => task.completed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tasks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<AddTaskButton onClick={handleAddTask} className="w-full" />
|
||||||
|
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
||||||
|
<p className={styles.emptyState}>
|
||||||
|
No tasks yet. Add your first task to get started!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incompleteTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => handleEditTask(task)}
|
||||||
|
onDelete={() => handleDeleteTask(task.id)}
|
||||||
|
onToggleComplete={() => handleToggleComplete(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{completedTasks.length > 0 && (
|
||||||
|
<div className={styles.completedSection}>
|
||||||
|
<h4 className={styles.completedTitle}>
|
||||||
|
Completed ({completedTasks.length})
|
||||||
|
</h4>
|
||||||
|
<div className={styles.completedList}>
|
||||||
|
{completedTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => handleEditTask(task)}
|
||||||
|
onDelete={() => handleDeleteTask(task.id)}
|
||||||
|
onToggleComplete={() => handleToggleComplete(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
|
<SheetContent side="right">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>
|
||||||
|
{editingTask ? "Edit Task" : "Add New Task"}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className={styles.sheetContent}>
|
||||||
|
<TaskForm
|
||||||
|
task={editingTask}
|
||||||
|
onSubmit={handleSubmitTask}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
149
client/src/components/organisms/todayProgress.tsx
Normal file
149
client/src/components/organisms/todayProgress.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useStore } from "@/lib/store";
|
||||||
|
import styles from "@/styles/TodayProgress.module.scss";
|
||||||
|
|
||||||
|
const TodayProgress = () => {
|
||||||
|
const localSessions = useStore((state) => state.localSessions);
|
||||||
|
const tasks = useStore((state) => state.tasks);
|
||||||
|
|
||||||
|
// Calculate today's date
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todaySessions = localSessions.filter((session) => {
|
||||||
|
const sessionDate = new Date(session.startedAt);
|
||||||
|
sessionDate.setHours(0, 0, 0, 0);
|
||||||
|
return sessionDate.getTime() === today.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
||||||
|
|
||||||
|
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
||||||
|
|
||||||
|
const todayFocusTime = completedToday.reduce(
|
||||||
|
(sum, s) => sum + s.intendedMinutes,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const completionRate =
|
||||||
|
todayFocusSessions.length > 0
|
||||||
|
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Task statistics
|
||||||
|
const todayTasks = tasks.filter((task) => {
|
||||||
|
const taskDate = new Date(task.createdAt);
|
||||||
|
taskDate.setHours(0, 0, 0, 0);
|
||||||
|
return taskDate.getTime() === today.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedTasks = todayTasks.filter((task) => task.completed);
|
||||||
|
const taskCompletionRate = todayTasks.length > 0
|
||||||
|
? Math.round((completedTasks.length / todayTasks.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const formatFocusTime = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
return `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Today's Progress</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={styles.cardContent}>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Sessions Completed
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={styles.statValue}
|
||||||
|
data-testid="text-today-completed"
|
||||||
|
>
|
||||||
|
{completedToday.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Focus Time
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={styles.statValue}
|
||||||
|
data-testid="text-today-focus-time"
|
||||||
|
>
|
||||||
|
{formatFocusTime(todayFocusTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Completion Rate
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.statValue} ${styles.completionValue}`}
|
||||||
|
data-testid="text-today-completion-rate"
|
||||||
|
>
|
||||||
|
{completionRate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${completionRate}%` }}
|
||||||
|
data-testid="progress-today-completion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Statistics */}
|
||||||
|
<div className={styles.taskStats}>
|
||||||
|
<div className={`${styles.statRow} ${styles.taskStatsTitle}`}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Tasks Created
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={styles.statValue}
|
||||||
|
data-testid="text-today-tasks-created"
|
||||||
|
>
|
||||||
|
{todayTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Tasks Completed
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={styles.statValue}
|
||||||
|
data-testid="text-today-tasks-completed"
|
||||||
|
>
|
||||||
|
{completedTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<span className={styles.statLabel}>
|
||||||
|
Task Completion Rate
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.statValue} ${styles.completionValue}`}
|
||||||
|
data-testid="text-today-task-completion-rate"
|
||||||
|
>
|
||||||
|
{taskCompletionRate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${taskCompletionRate}%` }}
|
||||||
|
data-testid="progress-today-task-completion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodayProgress;
|
@@ -31,6 +31,18 @@ export interface ShortcutsState {
|
|||||||
shortcutsEnabled: boolean;
|
shortcutsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
notes: string;
|
||||||
|
eta: number; // minutes
|
||||||
|
duration: number; // minutes spent
|
||||||
|
completed: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
// Timer state
|
// Timer state
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
@@ -41,6 +53,9 @@ interface Store {
|
|||||||
// Local session history
|
// Local session history
|
||||||
localSessions: Session[];
|
localSessions: Session[];
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
tasks: Task[];
|
||||||
|
|
||||||
// Device ID
|
// Device ID
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
|
||||||
@@ -54,6 +69,12 @@ interface Store {
|
|||||||
clearLocalSessions: () => void;
|
clearLocalSessions: () => void;
|
||||||
setDeviceId: (deviceId: string) => void;
|
setDeviceId: (deviceId: string) => void;
|
||||||
|
|
||||||
|
// Task actions
|
||||||
|
addTask: (task: Omit<Task, 'id' | 'createdAt'>) => void;
|
||||||
|
updateTask: (id: string, updates: Partial<Task>) => void;
|
||||||
|
deleteTask: (id: string) => void;
|
||||||
|
completeTask: (id: string) => void;
|
||||||
|
|
||||||
// Shortcuts actions
|
// Shortcuts actions
|
||||||
showShortcut: (shortcut: string) => void;
|
showShortcut: (shortcut: string) => void;
|
||||||
hideShortcut: () => void;
|
hideShortcut: () => void;
|
||||||
@@ -94,6 +115,7 @@ export const useStore = create<Store>()(
|
|||||||
timer: defaultTimer,
|
timer: defaultTimer,
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
localSessions: [],
|
localSessions: [],
|
||||||
|
tasks: [],
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
shortcuts: defaultShortcuts,
|
shortcuts: defaultShortcuts,
|
||||||
|
|
||||||
@@ -122,6 +144,39 @@ export const useStore = create<Store>()(
|
|||||||
deviceId,
|
deviceId,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
addTask: (task) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
...task,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
...state.tasks,
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateTask: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.map((task) =>
|
||||||
|
task.id === id ? { ...task, ...updates } : task
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteTask: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.filter((task) => task.id !== id),
|
||||||
|
})),
|
||||||
|
|
||||||
|
completeTask: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.map((task) =>
|
||||||
|
task.id === id
|
||||||
|
? { ...task, completed: true, completedAt: new Date() }
|
||||||
|
: task
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
showShortcut: (shortcut: string) =>
|
showShortcut: (shortcut: string) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
@@ -152,6 +207,7 @@ export const useStore = create<Store>()(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
localSessions: state.localSessions,
|
localSessions: state.localSessions,
|
||||||
|
tasks: state.tasks,
|
||||||
deviceId: state.deviceId,
|
deviceId: state.deviceId,
|
||||||
shortcuts: state.shortcuts,
|
shortcuts: state.shortcuts,
|
||||||
}),
|
}),
|
||||||
|
@@ -3,17 +3,15 @@ import { MainLayout } from "@/components/templates/MainLayout";
|
|||||||
import { PomodoroTimer } from "@/components/organisms/PomodoroTimer";
|
import { PomodoroTimer } from "@/components/organisms/PomodoroTimer";
|
||||||
import { SuggestionChips } from "@/components/molecules/SuggestionChips";
|
import { SuggestionChips } from "@/components/molecules/SuggestionChips";
|
||||||
import { DurationSelector } from "@/components/molecules/DurationSelector";
|
import { DurationSelector } from "@/components/molecules/DurationSelector";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Tasks } from "@/components/organisms/Tasks";
|
||||||
import { Download, Volume2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/atoms/Button";
|
|
||||||
import { useStore } from "@/lib/store";
|
import { useStore } from "@/lib/store";
|
||||||
import { getOrCreateDeviceId } from "@/lib/device";
|
import { getOrCreateDeviceId } from "@/lib/device";
|
||||||
|
import styles from "@/styles/Home.module.scss";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const timer = useStore((state) => state.timer);
|
const timer = useStore((state) => state.timer);
|
||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const deviceId = useStore((state) => state.deviceId);
|
const deviceId = useStore((state) => state.deviceId);
|
||||||
const localSessions = useStore((state) => state.localSessions);
|
|
||||||
const setTimer = useStore((state) => state.setTimer);
|
const setTimer = useStore((state) => state.setTimer);
|
||||||
const updateSettings = useStore((state) => state.updateSettings);
|
const updateSettings = useStore((state) => state.updateSettings);
|
||||||
const setDeviceId = useStore((state) => state.setDeviceId);
|
const setDeviceId = useStore((state) => state.setDeviceId);
|
||||||
@@ -53,162 +51,26 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportData = () => {
|
|
||||||
const dataStr = JSON.stringify(localSessions, null, 2);
|
|
||||||
const dataUri =
|
|
||||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
|
||||||
|
|
||||||
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split("T")[0]}.json`;
|
|
||||||
|
|
||||||
const linkElement = document.createElement("a");
|
|
||||||
linkElement.setAttribute("href", dataUri);
|
|
||||||
linkElement.setAttribute("download", exportFileDefaultName);
|
|
||||||
linkElement.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate today's progress
|
// Calculate today's progress
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const todaySessions = localSessions.filter((session) => {
|
|
||||||
const sessionDate = new Date(session.startedAt);
|
|
||||||
sessionDate.setHours(0, 0, 0, 0);
|
|
||||||
return sessionDate.getTime() === today.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
|
||||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
|
||||||
const todayFocusTime = completedToday.reduce(
|
|
||||||
(sum, s) => sum + s.intendedMinutes,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const completionRate =
|
|
||||||
todayFocusSessions.length > 0
|
|
||||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const formatFocusTime = (minutes: number) => {
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${mins}m`;
|
|
||||||
}
|
|
||||||
return `${mins}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout
|
<MainLayout
|
||||||
title="Focus Timer"
|
title="Focus Timer"
|
||||||
subtitle="Stay focused with the Pomodoro Technique"
|
subtitle="Stay focused with the Pomodoro Technique"
|
||||||
>
|
>
|
||||||
<div className="p-6 max-w-4xl mx-auto space-y-8 fade-in">
|
<div className={styles.container}>
|
||||||
{/* Time Suggestions */}
|
|
||||||
<SuggestionChips onSuggestionSelect={handleSuggestionSelect} />
|
<SuggestionChips onSuggestionSelect={handleSuggestionSelect} />
|
||||||
|
<div className={styles.grid}>
|
||||||
{/* Main Timer Section */}
|
<Tasks />
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* Timer Circle */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
{ /* Pomodoro Timer Component */ }
|
|
||||||
<PomodoroTimer />
|
|
||||||
|
|
||||||
{/* Today's Progress */}
|
|
||||||
<Card className="mt-6">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Today's Progress</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground break-words">
|
|
||||||
Sessions Completed
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-medium flex-shrink-0 ml-2"
|
|
||||||
data-testid="text-today-completed"
|
|
||||||
>
|
|
||||||
{completedToday.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground break-words">
|
|
||||||
Focus Time
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-medium flex-shrink-0 ml-2"
|
|
||||||
data-testid="text-today-focus-time"
|
|
||||||
>
|
|
||||||
{formatFocusTime(todayFocusTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground break-words">
|
|
||||||
Completion Rate
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-medium text-chart-2 flex-shrink-0 ml-2"
|
|
||||||
data-testid="text-today-completion-rate"
|
|
||||||
>
|
|
||||||
{completionRate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-muted rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-chart-2 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${completionRate}%` }}
|
|
||||||
data-testid="progress-today-completion"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration Selector & Settings */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Duration Presets */}
|
<PomodoroTimer />
|
||||||
<DurationSelector
|
<DurationSelector
|
||||||
selectedFocus={settings.focusDuration}
|
selectedFocus={settings.focusDuration}
|
||||||
selectedBreak={settings.shortBreakDuration}
|
selectedBreak={settings.shortBreakDuration}
|
||||||
onDurationChange={handleDurationChange}
|
onDurationChange={handleDurationChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-accent hover:bg-accent/5 min-h-[80px]"
|
|
||||||
data-testid="button-sound-settings"
|
|
||||||
>
|
|
||||||
<Volume2 className="text-accent flex-shrink-0" />
|
|
||||||
<div className="text-left flex-1 min-w-0">
|
|
||||||
<div className="font-medium break-words">
|
|
||||||
Sound Settings
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
|
||||||
Adjust notification sounds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-chart-2 hover:bg-chart-2/5 min-h-[80px]"
|
|
||||||
onClick={handleExportData}
|
|
||||||
data-testid="button-export-data"
|
|
||||||
>
|
|
||||||
<Download className="text-chart-2 flex-shrink-0" />
|
|
||||||
<div className="text-left flex-1 min-w-0">
|
|
||||||
<div className="font-medium break-words">Export Data</div>
|
|
||||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
|
||||||
Download session history
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
38
client/src/styles/Home.module.scss
Normal file
38
client/src/styles/Home.module.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 35% 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 30% 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
61
client/src/styles/QuickActions.module.scss
Normal file
61
client/src/styles/QuickActions.module.scss
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
.card {
|
||||||
|
// Card container styles
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundButton {
|
||||||
|
&:hover {
|
||||||
|
border-color: hsl(var(--accent));
|
||||||
|
background-color: hsl(var(--accent) / 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exportButton {
|
||||||
|
&:hover {
|
||||||
|
border-color: hsl(var(--chart-2));
|
||||||
|
background-color: hsl(var(--chart-2) / 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
text-align: left;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-words;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonDescription {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
word-break: break-words;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundIcon {
|
||||||
|
color: hsl(var(--accent));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exportIcon {
|
||||||
|
color: hsl(var(--chart-2));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
123
client/src/styles/TaskCard.module.scss
Normal file
123
client/src/styles/TaskCard.module.scss
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
.card {
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
||||||
|
transform: scale(1.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
background-color: hsl(var(--muted) / 0.5) !important;
|
||||||
|
cursor: default !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
padding: 1rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
line-height: 1.25 !important;
|
||||||
|
margin-bottom: 0.25rem !important;
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
text-decoration: line-through !important;
|
||||||
|
color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
color: hsl(var(--muted-foreground)) !important;
|
||||||
|
display: -webkit-box !important;
|
||||||
|
-webkit-line-clamp: 2 !important;
|
||||||
|
-webkit-box-orient: vertical !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaInfo {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaItem {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
border-radius: 0.25rem !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: hsl(var(--destructive) / 0.1) !important;
|
||||||
|
color: hsl(var(--destructive)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteIcon {
|
||||||
|
width: 1rem !important;
|
||||||
|
height: 1rem !important;
|
||||||
|
color: hsl(var(--destructive)) !important;
|
||||||
|
transition: color 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
align-self: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completionIndicator {
|
||||||
|
width: 1rem !important;
|
||||||
|
height: 1rem !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background-color: #22c55e !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin-top: 0.25rem !important;
|
||||||
|
}
|
41
client/src/styles/TaskForm.module.scss
Normal file
41
client/src/styles/TaskForm.module.scss
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.form {
|
||||||
|
// Form container styles
|
||||||
|
}
|
||||||
|
|
||||||
|
.formContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
// Individual field container
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
// Cancel button styles
|
||||||
|
}
|
56
client/src/styles/Tasks.module.scss
Normal file
56
client/src/styles/Tasks.module.scss
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedSection {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetContent {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
54
client/src/styles/TodayProgress.module.scss
Normal file
54
client/src/styles/TodayProgress.module.scss
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.card {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
word-break: break-words;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completionValue {
|
||||||
|
color: hsl(var(--chart-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
width: 100%;
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
background-color: hsl(var(--chart-2));
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskStats {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskStatsTitle {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
451
package-lock.json
generated
451
package-lock.json
generated
@@ -96,6 +96,7 @@
|
|||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
"sass": "^1.92.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
@@ -1477,6 +1478,330 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^1.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"micromatch": "^4.0.5",
|
||||||
|
"node-addon-api": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-android-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
|
"@parcel/watcher-freebsd-x64": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-ia32": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-android-arm64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-x64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-arm64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-ia32": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-x64": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher/node_modules/detect-libc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"detect-libc": "bin/detect-libc.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@petamoriken/float16": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||||
@@ -3423,6 +3748,66 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.0.4",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||||
@@ -5978,6 +6363,13 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immutable": {
|
||||||
|
"version": "5.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
|
||||||
|
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -6715,6 +7107,14 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
@@ -7831,6 +8231,57 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sass": {
|
||||||
|
"version": "1.92.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.92.0.tgz",
|
||||||
|
"integrity": "sha512-KDNI0BxgIRDAfJgzNm5wuy+4yOCIZyrUbjSpiU/JItfih+KGXAVefKL53MTml054MmBA3DDKIBMSI/7XLxZJ3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
|
"immutable": "^5.0.2",
|
||||||
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sass": "sass.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sass/node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sass/node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
|
@@ -99,6 +99,7 @@
|
|||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
|
"sass": "^1.92.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
|
Reference in New Issue
Block a user