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:
Carlos Gutierrez
2025-09-02 19:42:32 -04:00
parent 2faa54fa17
commit 8f9e4f54df
17 changed files with 1485 additions and 180 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View File

@@ -3,6 +3,8 @@ import { Button } from "@/components/atoms/Button";
import { useTheme } from "@/lib/theme";
import { useStore } from "@/lib/store";
import { Link, useLocation } from "wouter";
import TodayProgress from "@/components/organisms/todayProgress";
import QuickActions from "@/components/organisms/QuickActions";
interface SidebarProps {
className?: string;
@@ -17,24 +19,6 @@ export function Sidebar({
}: SidebarProps) {
const { theme, toggleTheme } = useTheme();
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 = [
{ path: "/", icon: Clock, label: "Timer", testId: "nav-timer" },
@@ -103,24 +87,11 @@ export function Sidebar({
{/* Theme Toggle & Stats */}
<div className="p-4 border-t border-border space-y-4">
{/* Quick Stats */}
<div className="bg-muted rounded-lg p-3 text-sm">
<div className="flex justify-between items-center mb-1">
<span className="text-muted-foreground">Today's Sessions</span>
<span className="font-medium" data-testid="stat-today-sessions">
{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>
{/* Today's Progress */}
<TodayProgress />
{/* Quick Actions */}
<QuickActions />
{/* Theme Toggle */}
<Button

View 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>
);
}

View 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;

View File

@@ -31,6 +31,18 @@ export interface ShortcutsState {
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 {
// Timer state
timer: TimerState;
@@ -41,6 +53,9 @@ interface Store {
// Local session history
localSessions: Session[];
// Tasks
tasks: Task[];
// Device ID
deviceId: string;
@@ -54,6 +69,12 @@ interface Store {
clearLocalSessions: () => 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
showShortcut: (shortcut: string) => void;
hideShortcut: () => void;
@@ -94,6 +115,7 @@ export const useStore = create<Store>()(
timer: defaultTimer,
settings: defaultSettings,
localSessions: [],
tasks: [],
deviceId: "",
shortcuts: defaultShortcuts,
@@ -122,6 +144,39 @@ export const useStore = create<Store>()(
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) =>
set((state) => ({
shortcuts: {
@@ -152,6 +207,7 @@ export const useStore = create<Store>()(
partialize: (state) => ({
settings: state.settings,
localSessions: state.localSessions,
tasks: state.tasks,
deviceId: state.deviceId,
shortcuts: state.shortcuts,
}),

View File

@@ -3,17 +3,15 @@ import { MainLayout } from "@/components/templates/MainLayout";
import { PomodoroTimer } from "@/components/organisms/PomodoroTimer";
import { SuggestionChips } from "@/components/molecules/SuggestionChips";
import { DurationSelector } from "@/components/molecules/DurationSelector";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Download, Volume2 } from "lucide-react";
import { Button } from "@/components/atoms/Button";
import { Tasks } from "@/components/organisms/Tasks";
import { useStore } from "@/lib/store";
import { getOrCreateDeviceId } from "@/lib/device";
import styles from "@/styles/Home.module.scss";
export default function Home() {
const timer = useStore((state) => state.timer);
const settings = useStore((state) => state.settings);
const deviceId = useStore((state) => state.deviceId);
const localSessions = useStore((state) => state.localSessions);
const setTimer = useStore((state) => state.setTimer);
const updateSettings = useStore((state) => state.updateSettings);
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
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;
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 (
<MainLayout
title="Focus Timer"
subtitle="Stay focused with the Pomodoro Technique"
>
<div className="p-6 max-w-4xl mx-auto space-y-8 fade-in">
{/* Time Suggestions */}
<div className={styles.container}>
<SuggestionChips onSuggestionSelect={handleSuggestionSelect} />
{/* Main Timer Section */}
<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={styles.grid}>
<Tasks />
<div className="space-y-6">
{/* Duration Presets */}
<PomodoroTimer />
<DurationSelector
selectedFocus={settings.focusDuration}
selectedBreak={settings.shortBreakDuration}
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>

View 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);
}
}

View 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;
}

View 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;
}

View 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
}

View 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;
}

View 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
View File

@@ -96,6 +96,7 @@
"drizzle-kit": "^0.30.4",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"sass": "^1.92.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.1",
"typescript": "5.6.3",
@@ -1477,6 +1478,330 @@
"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": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
@@ -3423,6 +3748,66 @@
"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": {
"version": "4.1.12",
"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"
}
},
"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": {
"version": "2.0.4",
"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"
}
},
"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": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -7831,6 +8231,57 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",

View File

@@ -99,6 +99,7 @@
"drizzle-kit": "^0.30.4",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"sass": "^1.92.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.1",
"typescript": "5.6.3",