Fix upcoming completed issue (#404)
* Fix upcoming completed issue * fixup! Fix upcoming completed issue * fixup! fixup! Fix upcoming completed issue * Fix completed icon plscement * fixup! Fix completed icon plscement * Add upcoming section tests
This commit is contained in:
parent
6efb565a4e
commit
a81ca2f2b6
9 changed files with 265 additions and 111 deletions
|
|
@ -2,24 +2,51 @@
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async (queryInterface, Sequelize) => {
|
||||||
// Update all projects: active=true -> state='in_progress', active=false -> state='idea'
|
try {
|
||||||
await queryInterface.sequelize.query(`
|
const tableInfo = await queryInterface.describeTable('projects');
|
||||||
UPDATE projects
|
|
||||||
SET state = CASE
|
// Only migrate if 'active' column exists
|
||||||
WHEN active = 1 THEN 'in_progress'
|
if ('active' in tableInfo) {
|
||||||
ELSE 'idea'
|
await queryInterface.sequelize.query(`
|
||||||
END
|
UPDATE projects
|
||||||
`);
|
SET state = CASE
|
||||||
|
WHEN active = 1 THEN 'in_progress'
|
||||||
|
ELSE 'idea'
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'Column active does not exist in projects table, skipping data migration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
'Migration error converting active to states:',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
// Don't throw - allow migration to continue since state column already exists
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
down: async (queryInterface, Sequelize) => {
|
||||||
// Reverse the conversion: state='in_progress' -> active=true, others -> active=false
|
try {
|
||||||
await queryInterface.sequelize.query(`
|
const tableInfo = await queryInterface.describeTable('projects');
|
||||||
UPDATE projects
|
|
||||||
SET active = CASE
|
// Only rollback if 'active' column exists
|
||||||
WHEN state = 'in_progress' THEN 1
|
if ('active' in tableInfo) {
|
||||||
ELSE 0
|
await queryInterface.sequelize.query(`
|
||||||
END
|
UPDATE projects
|
||||||
`);
|
SET active = CASE
|
||||||
|
WHEN state = 'in_progress' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
'Migration rollback error for active column:',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,8 @@ async function filterTasksByParams(params, userId, userTimezone) {
|
||||||
// Disable search functionality for upcoming view
|
// Disable search functionality for upcoming view
|
||||||
if (params.type === 'upcoming') {
|
if (params.type === 'upcoming') {
|
||||||
// Remove search-related parameters to prevent search functionality
|
// Remove search-related parameters to prevent search functionality
|
||||||
params = { ...params, client_side_filtering: false };
|
// Keep client_side_filtering to allow frontend to control completed task visibility
|
||||||
|
params = { ...params };
|
||||||
delete params.search;
|
delete params.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,16 +564,24 @@ async function filterTasksByParams(params, userId, userTimezone) {
|
||||||
const safeTimezone = getSafeTimezone(userTimezone);
|
const safeTimezone = getSafeTimezone(userTimezone);
|
||||||
const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7);
|
const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7);
|
||||||
|
|
||||||
|
console.log('📅 UPCOMING RANGE DEBUG:');
|
||||||
|
console.log(` Timezone: ${safeTimezone}`);
|
||||||
|
console.log(
|
||||||
|
` Range Start (UTC): ${upcomingRange.start.toISOString()}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Range End (UTC): ${upcomingRange.end.toISOString()}`
|
||||||
|
);
|
||||||
|
console.log(` User ID: ${userId}`);
|
||||||
|
|
||||||
// For upcoming view, we want to show recurring instances (children) with due dates
|
// For upcoming view, we want to show recurring instances (children) with due dates
|
||||||
// Override the default whereClause to include recurring instances
|
// Override the default whereClause to include recurring instances
|
||||||
// NOTE: Search functionality is disabled for upcoming view - ignore client_side_filtering
|
|
||||||
whereClause = {
|
whereClause = {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
parent_task_id: null, // Exclude subtasks from main task lists
|
parent_task_id: null, // Exclude subtasks from main task lists
|
||||||
due_date: {
|
due_date: {
|
||||||
[Op.between]: [upcomingRange.start, upcomingRange.end],
|
[Op.between]: [upcomingRange.start, upcomingRange.end],
|
||||||
},
|
},
|
||||||
status: { [Op.notIn]: [Task.STATUS.DONE, 'done'] },
|
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
// Include non-recurring tasks
|
// Include non-recurring tasks
|
||||||
{
|
{
|
||||||
|
|
@ -595,6 +604,15 @@ async function filterTasksByParams(params, userId, userTimezone) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Apply status filter based on client_side_filtering
|
||||||
|
if (params.status === 'done') {
|
||||||
|
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
|
||||||
|
} else if (!params.client_side_filtering) {
|
||||||
|
// Only exclude completed tasks if not doing client-side filtering
|
||||||
|
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
|
||||||
|
}
|
||||||
|
// If client_side_filtering is true, don't add any status filter (include all)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'next':
|
case 'next':
|
||||||
|
|
@ -1178,11 +1196,16 @@ router.get('/tasks', async (req, res) => {
|
||||||
// Debug logging for upcoming view
|
// Debug logging for upcoming view
|
||||||
if (req.query.type === 'upcoming') {
|
if (req.query.type === 'upcoming') {
|
||||||
console.log('🔍 UPCOMING TASKS DEBUG:');
|
console.log('🔍 UPCOMING TASKS DEBUG:');
|
||||||
tasks.forEach((task) => {
|
console.log(` Total tasks returned: ${tasks.length}`);
|
||||||
console.log(
|
if (tasks.length > 0) {
|
||||||
`- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}`
|
tasks.forEach((task) => {
|
||||||
);
|
console.log(
|
||||||
});
|
`- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}, Status: ${task.status}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' ⚠️ No tasks matched the query!');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group upcoming tasks by day of week if requested
|
// Group upcoming tasks by day of week if requested
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ function getTodayBoundsInUTC(userTimezone) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get date range for "upcoming" tasks (next N days) in user timezone
|
* Get date range for "upcoming" tasks (next N days) in user timezone
|
||||||
|
* Includes today through N days ahead
|
||||||
* @param {string} userTimezone - User's timezone
|
* @param {string} userTimezone - User's timezone
|
||||||
* @param {number} days - Number of days to look ahead (default: 7)
|
* @param {number} days - Number of days to look ahead (default: 7)
|
||||||
* @returns {Object} { start: Date, end: Date } - UTC Date objects
|
* @returns {Object} { start: Date, end: Date } - UTC Date objects
|
||||||
|
|
|
||||||
37
e2e/tests/upcoming-task.spec.ts
Normal file
37
e2e/tests/upcoming-task.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Shared login function
|
||||||
|
async function login(page: Page, baseURL: string | undefined) {
|
||||||
|
const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
await page.goto(appUrl + '/login');
|
||||||
|
|
||||||
|
const email = process.env.E2E_EMAIL || 'test@tududi.com';
|
||||||
|
const password = process.env.E2E_PASSWORD || 'password123';
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /login/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/today$/);
|
||||||
|
|
||||||
|
return appUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('upcoming view loads and displays upcoming section', async ({ page, baseURL }) => {
|
||||||
|
const appUrl = await login(page, baseURL);
|
||||||
|
|
||||||
|
// Navigate to upcoming view
|
||||||
|
await page.goto(appUrl + '/upcoming');
|
||||||
|
await expect(page).toHaveURL(/\/upcoming/);
|
||||||
|
|
||||||
|
// Verify the page heading is visible
|
||||||
|
await expect(page.getByRole('heading', { name: 'Upcoming' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for content to load
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify we don't have the task creation input (upcoming view is read-only)
|
||||||
|
const taskInput = page.locator('[data-testid="new-task-input"]');
|
||||||
|
await expect(taskInput).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
@ -240,27 +240,33 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
|
||||||
{/* Day column tasks */}
|
{/* Day column tasks */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{dayTasks.map((task) => (
|
{dayTasks.map((task) => (
|
||||||
<TaskItem
|
<div key={task.id}>
|
||||||
key={task.id}
|
<TaskItem
|
||||||
task={task}
|
task={task}
|
||||||
onTaskUpdate={
|
onTaskUpdate={
|
||||||
onTaskUpdate
|
onTaskUpdate
|
||||||
}
|
}
|
||||||
onTaskCompletionToggle={
|
onTaskCompletionToggle={
|
||||||
onTaskCompletionToggle
|
onTaskCompletionToggle
|
||||||
}
|
}
|
||||||
onTaskDelete={
|
onTaskDelete={
|
||||||
onTaskDelete
|
onTaskDelete
|
||||||
}
|
}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
hideProjectName={
|
hideProjectName={
|
||||||
hideProjectName
|
hideProjectName
|
||||||
}
|
}
|
||||||
onToggleToday={
|
onToggleToday={
|
||||||
onToggleToday
|
onToggleToday
|
||||||
}
|
}
|
||||||
isUpcomingView={true}
|
isUpcomingView={
|
||||||
/>
|
true
|
||||||
|
}
|
||||||
|
showCompletedTasks={
|
||||||
|
showCompletedTasks
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Empty state for columns with no tasks */}
|
{/* Empty state for columns with no tasks */}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ interface TaskItemProps {
|
||||||
hideProjectName?: boolean;
|
hideProjectName?: boolean;
|
||||||
onToggleToday?: (taskId: number) => Promise<void>;
|
onToggleToday?: (taskId: number) => Promise<void>;
|
||||||
isUpcomingView?: boolean;
|
isUpcomingView?: boolean;
|
||||||
|
showCompletedTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskItem: React.FC<TaskItemProps> = ({
|
const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
|
|
@ -151,6 +152,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
hideProjectName = false,
|
hideProjectName = false,
|
||||||
onToggleToday,
|
onToggleToday,
|
||||||
isUpcomingView = false,
|
isUpcomingView = false,
|
||||||
|
showCompletedTasks = false,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -162,6 +164,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
const [parentTaskModalOpen, setParentTaskModalOpen] = useState(false);
|
const [parentTaskModalOpen, setParentTaskModalOpen] = useState(false);
|
||||||
const [parentTask, setParentTask] = useState<Task | null>(null);
|
const [parentTask, setParentTask] = useState<Task | null>(null);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||||
|
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||||
|
|
||||||
// Subtasks state
|
// Subtasks state
|
||||||
const [showSubtasks, setShowSubtasks] = useState(false);
|
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||||
|
|
@ -313,6 +316,20 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
const handleToggleCompletion = async () => {
|
const handleToggleCompletion = async () => {
|
||||||
if (task.id) {
|
if (task.id) {
|
||||||
try {
|
try {
|
||||||
|
// Check if task is being completed (not uncompleted)
|
||||||
|
const isCompletingTask =
|
||||||
|
task.status !== 'done' &&
|
||||||
|
task.status !== 2 &&
|
||||||
|
task.status !== 'archived' &&
|
||||||
|
task.status !== 3;
|
||||||
|
|
||||||
|
// If completing the task in upcoming view and not showing completed tasks, trigger animation
|
||||||
|
if (isCompletingTask && isUpcomingView && !showCompletedTasks) {
|
||||||
|
setIsAnimatingOut(true);
|
||||||
|
// Wait for animation to complete before updating state
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
|
||||||
const response = await toggleTaskCompletion(task.id);
|
const response = await toggleTaskCompletion(task.id);
|
||||||
|
|
||||||
// Handle the updated task
|
// Handle the updated task
|
||||||
|
|
@ -366,6 +383,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling task completion:', error);
|
console.error('Error toggling task completion:', error);
|
||||||
|
setIsAnimatingOut(false); // Reset animation state on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -411,11 +429,11 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-all duration-200 ease-in-out ${
|
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-opacity duration-300 ease-in-out ${
|
||||||
isInProgress
|
isInProgress
|
||||||
? 'border-2 border-green-400/60 dark:border-green-500/60'
|
? 'border-2 border-green-400/60 dark:border-green-500/60'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
} ${isAnimatingOut ? 'opacity-0' : 'opacity-100'}`}
|
||||||
>
|
>
|
||||||
<TaskHeader
|
<TaskHeader
|
||||||
task={task}
|
task={task}
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,13 @@ const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<CheckCircleIcon
|
<CheckCircleIcon
|
||||||
className={`h-4 w-4 ${colorClass} cursor-pointer flex-shrink-0`}
|
className={`${colorClass} cursor-pointer flex-shrink-0 transition-all duration-300 ease-in-out animate-scale-in`}
|
||||||
style={{ width: '16px', height: '16px' }}
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
marginLeft: '-2px',
|
||||||
|
marginRight: '-2px',
|
||||||
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={getPriorityText()}
|
title={getPriorityText()}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
|
@ -95,7 +100,7 @@ const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-4 w-4 ${colorClass} cursor-pointer border-2 border-current rounded-full flex-shrink-0`}
|
className={`${colorClass} cursor-pointer border-2 border-current rounded-full flex-shrink-0 transition-all duration-300 ease-in-out hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20`}
|
||||||
style={{ width: '16px', height: '16px' }}
|
style={{ width: '16px', height: '16px' }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={getPriorityText()}
|
title={getPriorityText()}
|
||||||
|
|
|
||||||
|
|
@ -67,36 +67,42 @@ const Tasks: React.FC = () => {
|
||||||
const displayTasks = useMemo(() => {
|
const displayTasks = useMemo(() => {
|
||||||
let filteredTasks;
|
let filteredTasks;
|
||||||
|
|
||||||
// First filter by completion status
|
// For upcoming view, don't filter by completion status here
|
||||||
if (showCompleted) {
|
// Let GroupedTaskList handle it
|
||||||
// Show only completed tasks (done=2 or archived=3)
|
if (isUpcomingView) {
|
||||||
filteredTasks = tasks.filter(
|
filteredTasks = tasks;
|
||||||
(task) =>
|
|
||||||
task.status === 'done' ||
|
|
||||||
task.status === 'archived' ||
|
|
||||||
task.status === 2 ||
|
|
||||||
task.status === 3
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Show only non-completed tasks - exclude done(2) and archived(3)
|
// First filter by completion status
|
||||||
filteredTasks = tasks.filter(
|
if (showCompleted) {
|
||||||
(task) =>
|
// Show only completed tasks (done=2 or archived=3)
|
||||||
task.status !== 'done' &&
|
filteredTasks = tasks.filter(
|
||||||
task.status !== 'archived' &&
|
(task) =>
|
||||||
task.status !== 2 &&
|
task.status === 'done' ||
|
||||||
task.status !== 3
|
task.status === 'archived' ||
|
||||||
);
|
task.status === 2 ||
|
||||||
}
|
task.status === 3
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show only non-completed tasks - exclude done(2) and archived(3)
|
||||||
|
filteredTasks = tasks.filter(
|
||||||
|
(task) =>
|
||||||
|
task.status !== 'done' &&
|
||||||
|
task.status !== 'archived' &&
|
||||||
|
task.status !== 2 &&
|
||||||
|
task.status !== 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Then filter by search query if provided (skip for upcoming view)
|
// Then filter by search query if provided (skip for upcoming view)
|
||||||
if (taskSearchQuery.trim() && !isUpcomingView) {
|
if (taskSearchQuery.trim()) {
|
||||||
const query = taskSearchQuery.toLowerCase();
|
const query = taskSearchQuery.toLowerCase();
|
||||||
filteredTasks = filteredTasks.filter(
|
filteredTasks = filteredTasks.filter(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.name.toLowerCase().includes(query) ||
|
task.name.toLowerCase().includes(query) ||
|
||||||
task.original_name?.toLowerCase().includes(query) ||
|
task.original_name?.toLowerCase().includes(query) ||
|
||||||
task.note?.toLowerCase().includes(query)
|
task.note?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredTasks;
|
return filteredTasks;
|
||||||
|
|
@ -328,6 +334,23 @@ const Tasks: React.FC = () => {
|
||||||
task.id === updatedTask.id ? updatedTask : task
|
task.id === updatedTask.id ? updatedTask : task
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Also update groupedTasks if they exist
|
||||||
|
if (groupedTasks) {
|
||||||
|
setGroupedTasks((prevGroupedTasks) => {
|
||||||
|
if (!prevGroupedTasks) return null;
|
||||||
|
|
||||||
|
const newGroupedTasks: GroupedTasks = {};
|
||||||
|
Object.entries(prevGroupedTasks).forEach(
|
||||||
|
([groupName, tasks]) => {
|
||||||
|
newGroupedTasks[groupName] = tasks.map((task) =>
|
||||||
|
task.id === updatedTask.id ? updatedTask : task
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return newGroupedTasks;
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskDelete = async (taskId: number) => {
|
const handleTaskDelete = async (taskId: number) => {
|
||||||
|
|
@ -507,40 +530,38 @@ const Tasks: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isUpcomingView && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
Show completed
|
||||||
Show completed
|
</span>
|
||||||
</span>
|
<button
|
||||||
<button
|
onClick={() => setShowCompleted((v) => !v)}
|
||||||
onClick={() => setShowCompleted((v) => !v)}
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
showCompleted
|
||||||
|
? 'bg-blue-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
aria-pressed={showCompleted}
|
||||||
|
aria-label={
|
||||||
|
showCompleted
|
||||||
|
? 'Hide completed tasks'
|
||||||
|
: 'Show completed tasks'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
showCompleted
|
||||||
|
? 'Hide completed tasks'
|
||||||
|
: 'Show completed tasks'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
showCompleted
|
showCompleted
|
||||||
? 'bg-blue-600'
|
? 'translate-x-4'
|
||||||
: 'bg-gray-200 dark:bg-gray-600'
|
: 'translate-x-0.5'
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={showCompleted}
|
/>
|
||||||
aria-label={
|
</button>
|
||||||
showCompleted
|
</div>
|
||||||
? 'Hide completed tasks'
|
|
||||||
: 'Show completed tasks'
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
showCompleted
|
|
||||||
? 'Hide completed tasks'
|
|
||||||
: 'Show completed tasks'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
showCompleted
|
|
||||||
? 'translate-x-4'
|
|
||||||
: 'translate-x-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SortFilter
|
<SortFilter
|
||||||
sortOptions={sortOptions}
|
sortOptions={sortOptions}
|
||||||
sortValue={orderBy}
|
sortValue={orderBy}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,23 @@ module.exports = {
|
||||||
'./app/views/**/*.erb', // Any .erb templates that might remain
|
'./app/views/**/*.erb', // Any .erb templates that might remain
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
keyframes: {
|
||||||
|
'scale-in': {
|
||||||
|
'0%': { transform: 'scale(0.8)', opacity: '0.5' },
|
||||||
|
'50%': { transform: 'scale(1.1)' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
},
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'scale-in': 'scale-in 0.3s ease-out',
|
||||||
|
'fade-in': 'fade-in 0.3s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
// theme: {
|
// theme: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue