Lint frontend (#131)

* Add lint-fix npm target

* Sync eslint+plugins with backend

* Add prettier

* Ignore no-explicit-any lint rule for now

* Silence eslint react warning

* Format frontend via prettier

* Lint frontend.

---------

Co-authored-by: antanst <>
This commit is contained in:
Antonis Anastasiadis 2025-07-09 12:23:55 +03:00 committed by GitHub
parent f433dbffe3
commit 220bc92b4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 30271 additions and 48239 deletions

6
.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}

View file

@ -10,4 +10,14 @@ export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off"
},
settings: {
react: {
version: "18"
}
}
}
];

View file

@ -1,35 +1,30 @@
import React, { useEffect, useState, Suspense, lazy } from "react";
import {
Routes,
Route,
Navigate,
Outlet
} from "react-router-dom";
import { useTranslation } from "react-i18next";
import Login from "./components/Login";
import NotFound from "./components/Shared/NotFound";
import ProjectDetails from "./components/Project/ProjectDetails";
import Projects from "./components/Projects";
import AreaDetails from "./components/Area/AreaDetails";
import Areas from "./components/Areas";
import TagDetails from "./components/Tag/TagDetails";
import Tags from "./components/Tags";
import Notes from "./components/Notes";
import NoteDetails from "./components/Note/NoteDetails";
import Calendar from "./components/Calendar";
import ProfileSettings from "./components/Profile/ProfileSettings";
import About from "./components/About";
import Layout from "./Layout";
import { User } from "./entities/User";
import TasksToday from "./components/Task/TasksToday";
import TaskView from "./components/Task/TaskView";
import LoadingScreen from "./components/Shared/LoadingScreen";
import InboxItems from "./components/Inbox/InboxItems";
import React, { useEffect, useState, Suspense, lazy } from 'react';
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Login from './components/Login';
import NotFound from './components/Shared/NotFound';
import ProjectDetails from './components/Project/ProjectDetails';
import Projects from './components/Projects';
import AreaDetails from './components/Area/AreaDetails';
import Areas from './components/Areas';
import TagDetails from './components/Tag/TagDetails';
import Tags from './components/Tags';
import Notes from './components/Notes';
import NoteDetails from './components/Note/NoteDetails';
import Calendar from './components/Calendar';
import ProfileSettings from './components/Profile/ProfileSettings';
import About from './components/About';
import Layout from './Layout';
import { User } from './entities/User';
import TasksToday from './components/Task/TasksToday';
import TaskView from './components/Task/TaskView';
import LoadingScreen from './components/Shared/LoadingScreen';
import InboxItems from './components/Inbox/InboxItems';
// Lazy load Tasks component to prevent issues with tags loading
const Tasks = lazy(() => import("./components/Tasks"));
const Tasks = lazy(() => import('./components/Tasks'));
const App: React.FC = () => {
const { t, i18n } = useTranslation();
const { i18n } = useTranslation();
if (!i18n.isInitialized) {
return <LoadingScreen />;
@ -40,10 +35,10 @@ const App: React.FC = () => {
const fetchCurrentUser = async () => {
try {
const response = await fetch("/api/current_user", {
credentials: "include",
const response = await fetch('/api/current_user', {
credentials: 'include',
headers: {
Accept: "application/json",
Accept: 'application/json',
},
});
@ -61,7 +56,7 @@ const App: React.FC = () => {
} else {
setCurrentUser(null);
}
} catch (err) {
} catch {
setCurrentUser(null);
} finally {
setLoading(false);
@ -80,59 +75,77 @@ const App: React.FC = () => {
setCurrentUser(user);
};
window.addEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
return () => window.removeEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
window.addEventListener(
'userLoggedIn',
handleUserLoggedIn as EventListener
);
return () =>
window.removeEventListener(
'userLoggedIn',
handleUserLoggedIn as EventListener
);
}, []);
useEffect(() => {
if (i18n.isInitialized) {
fetch(`/locales/${i18n.language}/translation.json`)
.then(response => {
.then((response) => {
return response.json();
})
.then(data => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
})
.catch(error => {
console.error("Error manually fetching translation file:", error);
.catch((error) => {
console.error(
'Error manually fetching translation file:',
error
);
});
}
}, [i18n.isInitialized]);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem("isDarkMode");
const storedPreference = localStorage.getItem('isDarkMode');
return storedPreference !== null
? storedPreference === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
? storedPreference === 'true'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const toggleDarkMode = () => {
const newValue = !isDarkMode;
setIsDarkMode(newValue);
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
localStorage.setItem('isDarkMode', JSON.stringify(newValue));
};
useEffect(() => {
const updateTheme = () => {
document.documentElement.classList.toggle("dark", isDarkMode);
document.documentElement.classList.toggle('dark', isDarkMode);
};
updateTheme();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaListener = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("isDarkMode")) {
if (!localStorage.getItem('isDarkMode')) {
setIsDarkMode(e.matches);
}
};
mediaQuery.addEventListener("change", mediaListener);
return () => mediaQuery.removeEventListener("change", mediaListener);
mediaQuery.addEventListener('change', mediaListener);
return () => mediaQuery.removeEventListener('change', mediaListener);
}, [isDarkMode]);
const LoadingComponent = () => (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{i18n.t('common.loading', 'Loading application... Please wait.')}
{i18n.t(
'common.loading',
'Loading application... Please wait.'
)}
</div>
</div>
);
@ -158,28 +171,55 @@ const App: React.FC = () => {
</Layout>
}
>
<Route index element={<Navigate to="/today" replace />} />
<Route
index
element={<Navigate to="/today" replace />}
/>
<Route path="/today" element={<TasksToday />} />
<Route path="/task/:uuid" element={<TaskView />} />
<Route
path="/tasks"
element={
<Suspense fallback={<div className="p-4">{i18n.t('common.loading', 'Loading...')}</div>}>
<Suspense
fallback={
<div className="p-4">
{i18n.t(
'common.loading',
'Loading...'
)}
</div>
}
>
<Tasks />
</Suspense>
}
/>
<Route path="/inbox" element={<InboxItems />} />
<Route path="/projects" element={<Projects />} />
<Route path="/project/:id" element={<ProjectDetails />} />
<Route
path="/project/:id"
element={<ProjectDetails />}
/>
<Route path="/areas" element={<Areas />} />
<Route path="/area/:id" element={<AreaDetails />} />
<Route path="/tags" element={<Tags />} />
<Route path="/tag/:identifier" element={<TagDetails />} />
<Route
path="/tag/:identifier"
element={<TagDetails />}
/>
<Route path="/notes" element={<Notes />} />
<Route path="/note/:id" element={<NoteDetails />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />} />
<Route
path="/profile"
element={
<ProfileSettings
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
}
/>
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
@ -187,8 +227,14 @@ const App: React.FC = () => {
) : (
<>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
<Route
path="/"
element={<Navigate to="/login" replace />}
/>
<Route
path="*"
element={<Navigate to="/login" replace />}
/>
</>
)}
</Routes>

View file

@ -1,28 +1,32 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "./components/Shared/ToastContext";
import Navbar from "./components/Navbar";
import Sidebar from "./components/Sidebar";
import "./styles/tailwind.css";
import ProjectModal from "./components/Project/ProjectModal";
import NoteModal from "./components/Note/NoteModal";
import AreaModal from "./components/Area/AreaModal";
import TagModal from "./components/Tag/TagModal";
import InboxModal from "./components/Inbox/InboxModal";
import TaskModal from "./components/Task/TaskModal";
import { Note } from "./entities/Note";
import { Area } from "./entities/Area";
import { Tag } from "./entities/Tag";
import { Project } from "./entities/Project";
import { Task } from "./entities/Task";
import { User } from "./entities/User";
import { useStore } from "./store/useStore";
import { fetchNotes, createNote, updateNote } from "./utils/notesService";
import { fetchAreas, createArea, updateArea } from "./utils/areasService";
import { fetchTags, createTag, updateTag } from "./utils/tagsService";
import { fetchProjects, createProject, updateProject } from "./utils/projectsService";
import { createTask, updateTask } from "./utils/tasksService";
import { isAuthError } from "./utils/authUtils";
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from './components/Shared/ToastContext';
import Navbar from './components/Navbar';
import Sidebar from './components/Sidebar';
import './styles/tailwind.css';
import ProjectModal from './components/Project/ProjectModal';
import NoteModal from './components/Note/NoteModal';
import AreaModal from './components/Area/AreaModal';
import TagModal from './components/Tag/TagModal';
import InboxModal from './components/Inbox/InboxModal';
import TaskModal from './components/Task/TaskModal';
import { Note } from './entities/Note';
import { Area } from './entities/Area';
import { Tag } from './entities/Tag';
import { Project } from './entities/Project';
import { Task } from './entities/Task';
import { User } from './entities/User';
import { useStore } from './store/useStore';
import { fetchNotes, createNote, updateNote } from './utils/notesService';
import { fetchAreas, createArea, updateArea } from './utils/areasService';
import { fetchTags, createTag, updateTag } from './utils/tagsService';
import {
fetchProjects,
createProject,
updateProject,
} from './utils/projectsService';
import { createTask, updateTask } from './utils/tasksService';
import { isAuthError } from './utils/authUtils';
interface LayoutProps {
currentUser: User;
@ -41,18 +45,21 @@ const Layout: React.FC<LayoutProps> = ({
}) => {
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
const [isSidebarOpen, setIsSidebarOpen] = useState(
window.innerWidth >= 1024
);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>('simplified');
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>(
'simplified'
);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [newTask, setNewTask] = useState<Task | null>(null);
const {
notesStore: {
@ -71,12 +78,7 @@ const Layout: React.FC<LayoutProps> = ({
isLoading: isAreasLoading,
isError: isAreasError,
},
tasksStore: {
setLoading: setTasksLoading,
setError: setTasksError,
isLoading: isTasksLoading,
isError: isTasksError,
},
tasksStore: { isLoading: isTasksLoading, isError: isTasksError },
projectsStore: {
projects,
setProjects,
@ -100,14 +102,12 @@ const Layout: React.FC<LayoutProps> = ({
setTaskModalType(type);
};
useEffect(() => {
const handleResize = () => {
setIsSidebarOpen(window.innerWidth >= 1024);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const loadNotes = async () => {
@ -116,7 +116,7 @@ const Layout: React.FC<LayoutProps> = ({
const notesData = await fetchNotes();
setNotes(notesData);
} catch (error) {
console.error("Error fetching notes:", error);
console.error('Error fetching notes:', error);
setNotesError(true);
} finally {
setNotesLoading(false);
@ -129,7 +129,7 @@ const Layout: React.FC<LayoutProps> = ({
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error("Error fetching areas:", error);
console.error('Error fetching areas:', error);
setAreasError(true);
} finally {
setAreasLoading(false);
@ -142,7 +142,7 @@ const Layout: React.FC<LayoutProps> = ({
const tagsData = await fetchTags();
setTags(tagsData);
} catch (error) {
console.error("Error fetching tags:", error);
console.error('Error fetching tags:', error);
setTagsError(true);
} finally {
setTagsLoading(false);
@ -155,7 +155,7 @@ const Layout: React.FC<LayoutProps> = ({
const projectsData = await fetchProjects();
setProjects(projectsData);
} catch (error) {
console.error("Error fetching projects:", error);
console.error('Error fetching projects:', error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
@ -181,7 +181,6 @@ const Layout: React.FC<LayoutProps> = ({
const closeTaskModal = () => {
setIsTaskModalOpen(false);
setNewTask(null);
};
const openProjectModal = () => {
@ -222,7 +221,7 @@ const Layout: React.FC<LayoutProps> = ({
loadNotes();
closeNoteModal();
} catch (error: any) {
console.error("Error saving note:", error);
console.error('Error saving note:', error);
// Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) {
return;
@ -237,7 +236,14 @@ const Layout: React.FC<LayoutProps> = ({
await updateTask(taskData.id, taskData);
const taskLink = (
<span>
{t('task.updated', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{taskData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')}
{t('task.updated', 'Task')}{' '}
<a
href="/tasks"
className="text-green-200 underline hover:text-green-100"
>
{taskData.name}
</a>{' '}
{t('task.updatedSuccessfully', 'updated successfully!')}
</span>
);
showSuccessToast(taskLink);
@ -245,7 +251,14 @@ const Layout: React.FC<LayoutProps> = ({
const createdTask = await createTask(taskData);
const taskLink = (
<span>
{t('task.created', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
{t('task.created', 'Task')}{' '}
<a
href="/tasks"
className="text-green-200 underline hover:text-green-100"
>
{createdTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
@ -254,7 +267,7 @@ const Layout: React.FC<LayoutProps> = ({
// This prevents unnecessary re-renders and race conditions
closeTaskModal();
} catch (error: any) {
console.error("Error saving task:", error);
console.error('Error saving task:', error);
// Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) {
return;
@ -273,7 +286,7 @@ const Layout: React.FC<LayoutProps> = ({
});
return newProject;
} catch (error) {
console.error("Error creating project:", error);
console.error('Error creating project:', error);
throw error;
}
};
@ -289,7 +302,7 @@ const Layout: React.FC<LayoutProps> = ({
setProjects(projectsData);
closeProjectModal();
} catch (error: any) {
console.error("Error saving project:", error);
console.error('Error saving project:', error);
// Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) {
return;
@ -298,7 +311,6 @@ const Layout: React.FC<LayoutProps> = ({
}
};
const handleSaveArea = async (areaData: Partial<Area>) => {
try {
if (areaData.id) {
@ -309,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({
loadAreas();
closeAreaModal();
} catch (error: any) {
console.error("Error saving area:", error);
console.error('Error saving area:', error);
// Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) {
return;
@ -329,7 +341,7 @@ const Layout: React.FC<LayoutProps> = ({
setTags(tagsData);
closeTagModal();
} catch (error: any) {
console.error("Error saving tag:", error);
console.error('Error saving tag:', error);
// Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) {
return;
@ -338,24 +350,7 @@ const Layout: React.FC<LayoutProps> = ({
}
};
const handleLogout = async () => {
try {
const response = await fetch('/api/logout', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
setCurrentUser(null);
} else {
console.error('Logout failed:', await response.json());
}
} catch (error) {
console.error('Error during logout:', error);
}
};
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0';
const isLoading =
isNotesLoading ||
@ -372,7 +367,7 @@ const Layout: React.FC<LayoutProps> = ({
if (isLoading) {
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
@ -410,7 +405,7 @@ const Layout: React.FC<LayoutProps> = ({
if (isError) {
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
@ -438,14 +433,16 @@ const Layout: React.FC<LayoutProps> = ({
<div
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
>
<div className="text-xl text-red-500">{t('errors.somethingWentWrong')}</div>
<div className="text-xl text-red-500">
{t('errors.somethingWentWrong')}
</div>
</div>
</div>
);
}
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
@ -476,14 +473,15 @@ const Layout: React.FC<LayoutProps> = ({
>
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
<div className="flex-grow py-6 px-2 md:px-6 pt-24">
<div className="w-full max-w-5xl mx-auto">{children}</div>
<div className="w-full max-w-5xl mx-auto">
{children}
</div>
</div>
</div>
</div>
{isTaskModalOpen && (
taskModalType === 'simplified' ? (
{isTaskModalOpen &&
(taskModalType === 'simplified' ? (
<InboxModal
isOpen={isTaskModalOpen}
onClose={closeTaskModal}
@ -494,16 +492,15 @@ const Layout: React.FC<LayoutProps> = ({
isOpen={isTaskModalOpen}
onClose={closeTaskModal}
task={{
name: "",
status: "not_started",
name: '',
status: 'not_started',
}}
onSave={handleSaveTask}
onDelete={async () => {}}
projects={projects}
onCreateProject={handleCreateProject}
/>
)
)}
))}
{isProjectModalOpen && (
<ProjectModal
@ -512,7 +509,9 @@ const Layout: React.FC<LayoutProps> = ({
onSave={handleSaveProject}
onDelete={async (projectId) => {
try {
const { deleteProject } = await import('./utils/projectsService');
const { deleteProject } = await import(
'./utils/projectsService'
);
await deleteProject(projectId);
loadProjects();
closeProjectModal();
@ -531,7 +530,9 @@ const Layout: React.FC<LayoutProps> = ({
onSave={handleSaveNote}
onDelete={async (noteId) => {
try {
const { deleteNote } = await import('./utils/notesService');
const { deleteNote } = await import(
'./utils/notesService'
);
await deleteNote(noteId);
loadNotes();
closeNoteModal();

View file

@ -4,18 +4,18 @@ import { HeartIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
const About: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState<string>("0.3");
const [version, setVersion] = useState<string>('0.3');
useEffect(() => {
// Fetch version from the deployed app
fetch('/api/version')
.then(response => response.json())
.then(data => {
.then((response) => response.json())
.then((data) => {
if (data.version) {
setVersion(data.version);
}
})
.catch(error => {
.catch((error) => {
console.error('Error fetching version:', error);
// Keep default version if fetch fails
});
@ -26,7 +26,9 @@ const About: React.FC = () => {
<div className="w-full max-w-5xl">
<div className="flex items-center mb-4">
<InformationCircleIcon className="h-6 w-6 mr-2" />
<h2 className="text-2xl font-light">{t('about.title', 'About')}</h2>
<h2 className="text-2xl font-light">
{t('about.title', 'About')}
</h2>
</div>
<div className="max-w-2xl mx-auto">
@ -43,7 +45,10 @@ const About: React.FC = () => {
{/* Description */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-8">
<p className="text-gray-700 dark:text-gray-300 text-center leading-relaxed">
{t('about.description', 'Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration. Built with love for productivity enthusiasts.')}
{t(
'about.description',
'Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration. Built with love for productivity enthusiasts.'
)}
</p>
</div>
@ -56,14 +61,20 @@ const About: React.FC = () => {
</span>
</div>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{t('about.appreciation', 'Thank you for using tududi! Your support helps keep this project alive and growing. If you find it useful, consider supporting the development.')}
{t(
'about.appreciation',
'Thank you for using tududi! Your support helps keep this project alive and growing. If you find it useful, consider supporting the development.'
)}
</p>
</div>
{/* Support Links */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 text-center">
{t('about.supportDevelopment', 'Support Development')}
{t(
'about.supportDevelopment',
'Support Development'
)}
</h3>
<div className="grid grid-cols-2 gap-4">
<a
@ -72,7 +83,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M0 .5h4.219v23H0V.5zM15.384.5c4.767 0 8.616 3.718 8.616 8.313 0 4.596-3.85 8.313-8.616 8.313-4.767 0-8.615-3.717-8.615-8.313C6.769 4.218 10.617.5 15.384.5z" />
</svg>
Patreon
@ -83,7 +98,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-.766-1.623a4.44 4.44 0 0 0-1.209-.982c-.621-.37-1.294-.646-1.975-.804-.681-.158-1.375-.158-2.056 0-.682.158-1.354.434-1.975.804a4.44 4.44 0 0 0-1.209.982c-.378.46-.647 1.025-.766 1.623l-.132.666a.75.75 0 0 0 .735.885h8.568a.75.75 0 0 0 .735-.885zM11.5 9.5h1v8h-1v-8zM9 9.5h1v8H9v-8zM14 9.5h1v8h-1v-8z" />
</svg>
Buy Me a Coffee
@ -94,7 +113,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-white dark:bg-gray-800 border-2 border-gray-800 dark:border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-300 rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
GitHub Sponsors
@ -105,7 +128,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.13-.657c-.55-2.29-2.04-3.26-5.45-3.26H9.326L7.18 15.857h2.19c4.298 0 7.664-1.747 8.647-6.797.03-.149.054-.294.077-.437.206-1.314.064-2.285-.872-2.706z" />
</svg>
PayPal
@ -125,7 +152,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Official Website
@ -136,7 +167,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 font-medium"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" />
</svg>
Reddit
@ -147,7 +182,11 @@ const About: React.FC = () => {
rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors duration-200 font-medium col-span-2"
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" />
</svg>
Discord
@ -165,12 +204,19 @@ const About: React.FC = () => {
className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors duration-200"
>
{t('about.viewOnGitHub', 'View on GitHub')}
<svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 24 24">
<svg
className="w-4 h-4 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
<span className="block text-sm text-gray-500 dark:text-gray-400">
{t('about.license', 'Licensed for personal use')}
{t(
'about.license',
'Licensed for personal use'
)}
</span>
</div>
</div>
@ -178,7 +224,15 @@ const About: React.FC = () => {
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('about.builtBy', 'Built by')} <a href="https://github.com/chrisvel" target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">Chris Veleris</a>
{t('about.builtBy', 'Built by')}{' '}
<a
href="https://github.com/chrisvel"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Chris Veleris
</a>
</p>
</div>
</div>

View file

@ -48,7 +48,9 @@ const AreaDetails: React.FC = () => {
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('areas.details')}: {area?.name}
</h2>
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p>
<p className="text-md text-gray-700 dark:text-gray-300">
{area?.description}
</p>
<Link
to={`/projects?area_id=${area?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"

View file

@ -12,7 +12,13 @@ interface AreaModalProps {
area?: Area | null;
}
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, onDelete }) => {
const AreaModal: React.FC<AreaModalProps> = ({
isOpen,
onClose,
area,
onSave,
onDelete,
}) => {
const { t } = useTranslation();
const [formData, setFormData] = useState<Area>({
id: area?.id || 0,
@ -92,7 +98,11 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
try {
await onSave(formData);
showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated'));
showSuccessToast(
formData.id
? t('success.areaUpdated')
: t('success.areaCreated')
);
handleClose();
} catch (err) {
setError((err as Error).message);
@ -114,11 +124,15 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
if (formData.id && formData.id !== 0 && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(t('success.areaDeleted', 'Area deleted successfully!'));
showSuccessToast(
t('success.areaDeleted', 'Area deleted successfully!')
);
handleClose();
} catch (err) {
setError((err as Error).message);
showErrorToast(t('errors.failedToDeleteArea', 'Failed to delete area.'));
showErrorToast(
t('errors.failedToDeleteArea', 'Failed to delete area.')
);
}
}
};
@ -128,21 +142,24 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
return (
<div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100"
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="h-full flex items-center justify-center sm:px-4 sm:py-4">
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]">
{/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}>
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */}
@ -155,7 +172,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
onChange={handleChange}
required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.areaNamePlaceholder')}
placeholder={t(
'forms.areaNamePlaceholder'
)}
/>
</div>
@ -167,13 +186,21 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
value={formData.description}
onChange={handleChange}
className="block w-full h-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out resize-none"
placeholder={t('forms.areaDescriptionPlaceholder')}
style={{ minHeight: '150px' }}
placeholder={t(
'forms.areaDescriptionPlaceholder'
)}
style={{
minHeight: '150px',
}}
/>
</div>
{/* Error Message */}
{error && <div className="text-red-500 px-4 mb-4">{error}</div>}
{error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset>
</form>
</div>
@ -183,12 +210,18 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{(area && area.id && area.id !== 0 && onDelete) && (
{area &&
area.id &&
area.id !== 0 &&
onDelete && (
<button
type="button"
onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
@ -208,7 +241,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
onClick={handleSubmit}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isSubmitting

View file

@ -9,16 +9,24 @@ import {
import ConfirmDialog from './Shared/ConfirmDialog';
import AreaModal from './Area/AreaModal';
import { useStore } from '../store/useStore';
import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasService';
import {
fetchAreas,
createArea,
updateArea,
deleteArea,
} from '../utils/areasService';
import { Area } from '../entities/Area';
const Areas: React.FC = () => {
const { t } = useTranslation();
const { areas, setAreas, setLoading, setError } = useStore((state) => state.areasStore);
const { areas, setAreas, setLoading, setError } = useStore(
(state) => state.areasStore
);
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
const [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null);
@ -67,11 +75,6 @@ const Areas: React.FC = () => {
setIsAreaModalOpen(true);
};
const handleCreateArea = () => {
setSelectedArea(null);
setIsAreaModalOpen(true);
};
const openConfirmDialog = (area: Area) => {
setAreaToDelete(area);
setIsConfirmDialogOpen(true);
@ -115,14 +118,18 @@ const Areas: React.FC = () => {
{/* Areas List */}
{areas.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">{t('areas.noAreasFound')}</p>
<p className="text-gray-700 dark:text-gray-300">
{t('areas.noAreasFound')}
</p>
) : (
<ul className="space-y-2">
{areas.map((area) => (
<li
key={area.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
onMouseEnter={() => setHoveredAreaId(area.id || null)}
onMouseEnter={() =>
setHoveredAreaId(area.id || null)
}
onMouseLeave={() => setHoveredAreaId(null)}
>
{/* Area Content */}
@ -145,20 +152,34 @@ const Areas: React.FC = () => {
<button
onClick={() => handleEditArea(area)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${
hoveredAreaId === area.id ? 'opacity-100' : 'opacity-0'
hoveredAreaId === area.id
? 'opacity-100'
: 'opacity-0'
}`}
aria-label={t('areas.editAreaAriaLabel', { name: area.name })}
title={t('areas.editAreaTitle', { name: area.name })}
aria-label={t(
'areas.editAreaAriaLabel',
{ name: area.name }
)}
title={t('areas.editAreaTitle', {
name: area.name,
})}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => openConfirmDialog(area)}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${
hoveredAreaId === area.id ? 'opacity-100' : 'opacity-0'
hoveredAreaId === area.id
? 'opacity-100'
: 'opacity-0'
}`}
aria-label={t('areas.deleteAreaAriaLabel', { name: area.name })}
title={t('areas.deleteAreaTitle', { name: area.name })}
aria-label={t(
'areas.deleteAreaAriaLabel',
{ name: area.name }
)}
title={t('areas.deleteAreaTitle', {
name: area.name,
})}
>
<TrashIcon className="h-5 w-5" />
</button>
@ -194,7 +215,9 @@ const Areas: React.FC = () => {
{isConfirmDialogOpen && areaToDelete && (
<ConfirmDialog
title={t('modals.deleteArea.title')}
message={t('modals.deleteArea.message', { name: areaToDelete.name })}
message={t('modals.deleteArea.message', {
name: areaToDelete.name,
})}
onConfirm={handleDeleteArea}
onCancel={closeConfirmDialog}
/>

View file

@ -8,9 +8,8 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CalendarIcon,
PlusIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline';
import { format, addWeeks, addDays } from 'date-fns';
import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
@ -20,12 +19,18 @@ import CalendarDayView from './Calendar/CalendarDayView';
const getLocale = (language: string) => {
switch (language) {
case 'el': return el;
case 'es': return es;
case 'jp': return ja;
case 'ua': return uk;
case 'de': return de;
default: return enUS;
case 'el':
return el;
case 'es':
return es;
case 'jp':
return ja;
case 'ua':
return uk;
case 'de':
return de;
default:
return enUS;
}
};
@ -47,7 +52,9 @@ const Calendar: React.FC = () => {
const { t, i18n } = useTranslation();
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
const [googleStatus, setGoogleStatus] = useState<GoogleCalendarStatus>({ connected: false });
const [googleStatus, setGoogleStatus] = useState<GoogleCalendarStatus>({
connected: false,
});
const [isConnecting, setIsConnecting] = useState(false);
const [isDemoMode, setIsDemoMode] = useState(false);
const [events, setEvents] = useState<CalendarEvent[]>([]);
@ -70,18 +77,25 @@ const Calendar: React.FC = () => {
// Check URL parameters for demo mode
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('demo') === 'true' && urlParams.get('connected') === 'true') {
if (
urlParams.get('demo') === 'true' &&
urlParams.get('connected') === 'true'
) {
setGoogleStatus({ connected: true, email: 'demo@example.com' });
setIsDemoMode(true);
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
window.history.replaceState(
{},
document.title,
window.location.pathname
);
}
}, []);
const checkGoogleCalendarStatus = async () => {
try {
const response = await fetch('/api/calendar/status', {
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
const status = await response.json();
@ -97,7 +111,7 @@ const Calendar: React.FC = () => {
setIsLoadingTasks(true);
try {
const response = await fetch('/api/tasks', {
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
@ -138,8 +152,7 @@ const Calendar: React.FC = () => {
return [];
}
tasks.forEach((task, index) => {
tasks.forEach((task) => {
// Add tasks with due dates
if (task.due_date) {
const dueDate = new Date(task.due_date);
@ -149,7 +162,7 @@ const Calendar: React.FC = () => {
start: dueDate,
end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration
type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#ef4444' // Green if completed, red if not
color: task.completed_at ? '#22c55e' : '#ef4444', // Green if completed, red if not
};
taskEvents.push(taskEvent);
}
@ -167,7 +180,7 @@ const Calendar: React.FC = () => {
start: createdDate,
end: new Date(createdDate.getTime() + 30 * 60 * 1000), // 30 min duration
type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#3b82f6' // Green if completed, blue if not
color: task.completed_at ? '#22c55e' : '#3b82f6', // Green if completed, blue if not
};
taskEvents.push(taskEvent);
}
@ -181,7 +194,7 @@ const Calendar: React.FC = () => {
start: new Date(), // Today
end: new Date(Date.now() + 30 * 60 * 1000), // 30 min duration
type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#8b5cf6' // Green if completed, purple if not
color: task.completed_at ? '#22c55e' : '#8b5cf6', // Green if completed, purple if not
};
taskEvents.push(taskEvent);
}
@ -193,7 +206,7 @@ const Calendar: React.FC = () => {
const loadProjects = async () => {
try {
const response = await fetch('/api/projects', {
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
const projectsData = await response.json();
@ -210,13 +223,16 @@ const Calendar: React.FC = () => {
setIsConnecting(true);
try {
const response = await fetch('/api/calendar/auth', {
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
const result = await response.json();
if (result.demo) {
// Demo mode - simulate connection
setGoogleStatus({ connected: true, email: 'demo@example.com' });
setGoogleStatus({
connected: true,
email: 'demo@example.com',
});
setIsDemoMode(true);
} else {
// Real Google OAuth - redirect to auth URL
@ -245,7 +261,7 @@ const Calendar: React.FC = () => {
// Real disconnect API call
const response = await fetch('/api/calendar/disconnect', {
method: 'POST',
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
setGoogleStatus({ connected: false });
@ -259,7 +275,7 @@ const Calendar: React.FC = () => {
};
const navigate = (direction: 'prev' | 'next') => {
setCurrentDate(prev => {
setCurrentDate((prev) => {
if (view === 'month') {
const newDate = new Date(prev);
if (direction === 'prev') {
@ -269,9 +285,14 @@ const Calendar: React.FC = () => {
}
return newDate;
} else if (view === 'week') {
return direction === 'prev' ? addWeeks(prev, -1) : addWeeks(prev, 1);
} else { // day
return direction === 'prev' ? addDays(prev, -1) : addDays(prev, 1);
return direction === 'prev'
? addWeeks(prev, -1)
: addWeeks(prev, 1);
} else {
// day
return direction === 'prev'
? addDays(prev, -1)
: addDays(prev, 1);
}
});
};
@ -285,12 +306,11 @@ const Calendar: React.FC = () => {
};
const handleEventClick = (event: CalendarEvent) => {
// Handle task events
if (event.type === 'task') {
// Extract task ID from event ID
const taskId = event.id.replace(/^task(-created|-fallback)?-/, '');
const task = allTasks.find(t => t.id.toString() === taskId);
const task = allTasks.find((t) => t.id.toString() === taskId);
if (task) {
// Convert task to proper Task entity format for TaskModal
@ -305,7 +325,7 @@ const Calendar: React.FC = () => {
due_date: task.due_date,
created_at: task.created_at,
completed_at: task.completed_at,
project_id: task.project_id
project_id: task.project_id,
};
setSelectedTask(taskEntity);
@ -325,7 +345,9 @@ const Calendar: React.FC = () => {
const handleTaskSave = (updatedTask: Task) => {
// Update the task in allTasks
setAllTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t));
setAllTasks((prev) =>
prev.map((t) => (t.id === updatedTask.id ? updatedTask : t))
);
// Refresh calendar
loadTasks();
// Close modal
@ -337,7 +359,7 @@ const Calendar: React.FC = () => {
try {
await deleteTask(taskId);
// Remove task from allTasks
setAllTasks(prev => prev.filter(t => t.id !== taskId));
setAllTasks((prev) => prev.filter((t) => t.id !== taskId));
// Refresh calendar
loadTasks();
// Close modal
@ -354,12 +376,12 @@ const Calendar: React.FC = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, description: '' })
body: JSON.stringify({ name, description: '' }),
});
if (response.ok) {
const newProject = await response.json();
setProjects(prev => [...prev, newProject]);
setProjects((prev) => [...prev, newProject]);
return newProject;
} else {
throw new Error('Failed to create project');
@ -391,7 +413,11 @@ const Calendar: React.FC = () => {
{['month', 'week', 'day'].map((viewType) => (
<button
key={viewType}
onClick={() => setView(viewType as 'month' | 'week' | 'day')}
onClick={() =>
setView(
viewType as 'month' | 'week' | 'day'
)
}
className={`px-3 py-1 text-sm font-medium capitalize ${
view === viewType
? 'bg-blue-500 text-white'
@ -434,7 +460,6 @@ const Calendar: React.FC = () => {
</div>
)}
{/* Calendar view */}
{view === 'month' && (
<CalendarMonthView
@ -474,18 +499,20 @@ const Calendar: React.FC = () => {
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{isDemoMode
? 'Demo mode: Google Calendar integration simulated for testing purposes.'
: t('calendar.googleDescription')
}
: t('calendar.googleDescription')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{t('calendar.googleStatus')}:
{googleStatus.connected ? (
<span className="text-green-500 ml-1">
{t('calendar.connected')}
{googleStatus.email && ` (${googleStatus.email})`}
{googleStatus.email &&
` (${googleStatus.email})`}
</span>
) : (
<span className="text-red-500 ml-1">{t('calendar.notConnected')}</span>
<span className="text-red-500 ml-1">
{t('calendar.notConnected')}
</span>
)}
</p>
</div>
@ -502,7 +529,9 @@ const Calendar: React.FC = () => {
disabled={isConnecting}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{isConnecting ? t('calendar.connecting') : t('calendar.connectGoogle')}
{isConnecting
? t('calendar.connecting')
: t('calendar.connectGoogle')}
</button>
)}
</div>
@ -549,7 +578,12 @@ interface TaskEventModalProps {
onEditTask: () => void;
}
const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose, onEditTask }) => {
const TaskEventModal: React.FC<TaskEventModalProps> = ({
isOpen,
task,
onClose,
onEditTask,
}) => {
const { t, i18n } = useTranslation();
const locale = getLocale(i18n.language);
@ -587,12 +621,16 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.status')}
</label>
<div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
task.completed_at
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}>
{task.completed_at ? `${t('calendar.completed')}` : `${t('calendar.pending')}`}
}`}
>
{task.completed_at
? `${t('calendar.completed')}`
: `${t('calendar.pending')}`}
</span>
</div>
</div>
@ -604,7 +642,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.dueDate')}
</label>
<p className="text-gray-900 dark:text-gray-100">
{format(new Date(task.due_date), 'PPP', { locale: locale })}
{format(new Date(task.due_date), 'PPP', {
locale: locale,
})}
</p>
</div>
)}
@ -615,13 +655,15 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('calendar.priority')}
</label>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
task.priority === 'high'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: task.priority === 'medium'
? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
}`}>
}`}
>
{t(`calendar.${task.priority}`)}
</span>
</div>
@ -660,7 +702,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.created')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
{format(new Date(task.created_at), 'PPp', { locale: locale })}
{format(new Date(task.created_at), 'PPp', {
locale: locale,
})}
</p>
</div>
)}

View file

@ -1,6 +1,5 @@
import React from 'react';
import { format, addHours, isToday } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CalendarEvent {
id: string;
@ -22,14 +21,12 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
currentDate,
events,
onEventClick,
onTimeSlotClick
onTimeSlotClick,
}) => {
const { t } = useTranslation();
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (hour: number) => {
return events.filter(event => {
return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
@ -67,14 +64,22 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="text-center">
<div className={`text-lg font-medium ${
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'
}`}>
<div
className={`text-lg font-medium ${
isToday(currentDate)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{format(currentDate, 'EEEE')}
</div>
<div className={`text-2xl font-bold ${
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'
}`}>
<div
className={`text-2xl font-bold ${
isToday(currentDate)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{format(currentDate, 'd')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
@ -85,27 +90,41 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* All day events */}
<div className="p-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">All day</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
All day
</div>
<div className="space-y-1">
{events
.filter(event => {
.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd');
const currentDay = format(
currentDate,
'yyyy-MM-dd'
);
// Check if it's an all-day event (spans 24 hours or more)
const duration = event.end.getTime() - event.start.getTime();
return eventDay === currentDay && duration >= 24 * 60 * 60 * 1000;
const duration =
event.end.getTime() - event.start.getTime();
return (
eventDay === currentDay &&
duration >= 24 * 60 * 60 * 1000
);
})
.map(event => (
.map((event) => (
<div
key={event.id}
onClick={(e) => handleEventClick(event, e)}
className={`text-xs p-2 rounded text-white cursor-pointer hover:opacity-80 transition-opacity ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`}
style={{ backgroundColor: event.color || '#3b82f6' }}
style={{
backgroundColor: event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}{event.title}
{event.type === 'task' && '📋 '}
{event.title}
</div>
))}
</div>
@ -113,15 +132,24 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
{hours.map(hour => {
{hours.map((hour) => {
const timeSlotEvents = getEventsForTimeSlot(hour);
return (
<div key={hour} className="relative border-b border-gray-100 dark:border-gray-800">
<div
key={hour}
className="relative border-b border-gray-100 dark:border-gray-800"
>
<div className="flex">
{/* Time column */}
<div className="w-16 p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')}
{format(
addHours(
new Date().setHours(hour, 0, 0, 0),
0
),
'HH:mm'
)}
</div>
{/* Event area */}
@ -129,23 +157,36 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
onClick={() => handleTimeSlotClick(hour)}
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
>
{timeSlotEvents.map(event => (
{timeSlotEvents.map((event) => (
<div
key={event.id}
onClick={(e) => handleEventClick(event, e)}
onClick={(e) =>
handleEventClick(event, e)
}
className={`absolute left-1 right-1 text-xs p-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity z-10 ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`}
style={{
backgroundColor: event.color || '#3b82f6',
top: calculateEventPosition(event),
height: calculateEventHeight(event)
backgroundColor:
event.color || '#3b82f6',
top: calculateEventPosition(
event
),
height: calculateEventHeight(
event
),
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
>
<div className="font-medium">{event.type === 'task' && '📋 '}{event.title}</div>
<div className="font-medium">
{event.type === 'task' && '📋 '}
{event.title}
</div>
<div className="text-xs opacity-90">
{format(event.start, 'HH:mm')} - {format(event.end, 'HH:mm')}
{format(event.start, 'HH:mm')} -{' '}
{format(event.end, 'HH:mm')}
</div>
</div>
))}

View file

@ -1,5 +1,14 @@
import React from 'react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, startOfWeek, endOfWeek } from 'date-fns';
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isToday,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CalendarEvent {
@ -22,7 +31,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
currentDate,
events,
onDateClick,
onEventClick
onEventClick,
}) => {
const { t } = useTranslation();
@ -33,7 +42,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
const days = eachDayOfInterval({
start: calendarStart,
end: calendarEnd
end: calendarEnd,
});
const weekDays = [
@ -43,7 +52,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
t('weekdays.thursday', 'Thu'),
t('weekdays.friday', 'Fri'),
t('weekdays.saturday', 'Sat'),
t('weekdays.sunday', 'Sun')
t('weekdays.sunday', 'Sun'),
];
const handleDateClick = (date: Date) => {
@ -63,8 +72,11 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* Week days header */}
<div className="grid grid-cols-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{weekDays.map(day => (
<div key={day} className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
{weekDays.map((day) => (
<div
key={day}
className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400"
>
{day}
</div>
))}
@ -72,9 +84,11 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
{/* Calendar grid */}
<div className="grid grid-cols-7">
{days.map(day => {
const dayEvents = events.filter(event =>
format(event.start, 'yyyy-MM-dd') === format(day, 'yyyy-MM-dd')
{days.map((day) => {
const dayEvents = events.filter(
(event) =>
format(event.start, 'yyyy-MM-dd') ===
format(day, 'yyyy-MM-dd')
);
const isCurrentMonth = isSameMonth(day, currentDate);
@ -90,11 +104,13 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
: 'bg-white dark:bg-gray-900'
} ${isTodayDate ? 'bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600' : ''}`}
>
<div className={`text-sm mb-2 ${
<div
className={`text-sm mb-2 ${
!isCurrentMonth
? 'text-gray-400 dark:text-gray-600'
: 'text-gray-900 dark:text-gray-100'
} ${isTodayDate ? 'font-bold text-blue-600 dark:text-blue-400' : ''}`}>
} ${isTodayDate ? 'font-bold text-blue-600 dark:text-blue-400' : ''}`}
>
{isTodayDate && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full">
{format(day, 'd')}
@ -105,17 +121,25 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
{/* Events */}
<div className="space-y-1">
{dayEvents.slice(0, 3).map(event => (
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
onClick={(e) => handleEventClick(event, e)}
onClick={(e) =>
handleEventClick(event, e)
}
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`}
style={{ backgroundColor: event.color || '#3b82f6' }}
style={{
backgroundColor:
event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}{event.title}
{event.type === 'task' && '📋 '}
{event.title}
</div>
))}
{dayEvents.length > 3 && (

View file

@ -1,6 +1,12 @@
import React from 'react';
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isToday, addHours } from 'date-fns';
import { useTranslation } from 'react-i18next';
import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
addHours,
} from 'date-fns';
interface CalendarEvent {
id: string;
@ -22,12 +28,9 @@ interface CalendarWeekViewProps {
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
currentDate,
events,
onDateClick,
onEventClick,
onTimeSlotClick
onTimeSlotClick,
}) => {
const { t } = useTranslation();
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd });
@ -35,7 +38,7 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (day: Date, hour: number) => {
return events.filter(event => {
return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const slotDay = format(day, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
@ -64,18 +67,29 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
<div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Time
</div>
{weekDays.map(day => (
<div key={day.toString()} className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${
{weekDays.map((day) => (
<div
key={day.toString()}
className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${
isToday(day) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}>
<div className={`text-sm font-medium ${
isToday(day) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'
}`}>
}`}
>
<div
className={`text-sm font-medium ${
isToday(day)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{format(day, 'EEE')}
</div>
<div className={`text-lg ${
isToday(day) ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-gray-600 dark:text-gray-400'
}`}>
<div
className={`text-lg ${
isToday(day)
? 'text-blue-600 dark:text-blue-400 font-bold'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{isToday(day) ? (
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-600 text-white text-sm font-bold rounded-full">
{format(day, 'd')}
@ -90,36 +104,57 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
{hours.map(hour => (
<div key={hour} className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800">
{hours.map((hour) => (
<div
key={hour}
className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800"
>
{/* Time column */}
<div className="p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')}
{format(
addHours(new Date().setHours(hour, 0, 0, 0), 0),
'HH:mm'
)}
</div>
{/* Day columns */}
{weekDays.map(day => {
const timeSlotEvents = getEventsForTimeSlot(day, hour);
{weekDays.map((day) => {
const timeSlotEvents = getEventsForTimeSlot(
day,
hour
);
return (
<div
key={`${day.toString()}-${hour}`}
onClick={() => handleTimeSlotClick(day, hour)}
onClick={() =>
handleTimeSlotClick(day, hour)
}
className={`h-12 p-1 border-l border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative ${
isToday(day) ? 'bg-blue-50/30 dark:bg-blue-900/10' : ''
isToday(day)
? 'bg-blue-50/30 dark:bg-blue-900/10'
: ''
}`}
>
{timeSlotEvents.map(event => (
{timeSlotEvents.map((event) => (
<div
key={event.id}
onClick={(e) => handleEventClick(event, e)}
onClick={(e) =>
handleEventClick(event, e)
}
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity absolute inset-1 ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`}
style={{ backgroundColor: event.color || '#3b82f6' }}
style={{
backgroundColor:
event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
>
{event.type === 'task' && '📋 '}{event.title}
{event.type === 'task' && '📋 '}
{event.title}
</div>
))}
</div>

View file

@ -1,11 +1,17 @@
import React, { useState } from 'react';
import { InboxItem } from '../../entities/InboxItem';
import { useTranslation } from 'react-i18next';
import { TrashIcon, PencilIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon, TagIcon } from '@heroicons/react/24/outline';
import {
TrashIcon,
PencilIcon,
DocumentTextIcon,
FolderIcon,
ClipboardDocumentListIcon,
TagIcon,
} from '@heroicons/react/24/outline';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note';
import { useToast } from '../Shared/ToastContext';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useStore } from '../../store/useStore';
@ -21,16 +27,17 @@ interface InboxItemDetailProps {
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
item,
onProcess,
onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
onDelete,
onUpdate,
openTaskModal,
openProjectModal,
openNoteModal
openNoteModal,
}) => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
const { tagsStore: { tags } } = useStore();
const {
tagsStore: { tags },
} = useStore();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@ -39,16 +46,18 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
const parseHashtags = (text: string): string[] => {
const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
const matches = text.match(hashtagRegex);
return matches ? matches.map(tag => tag.substring(1)) : [];
return matches ? matches.map((tag) => tag.substring(1)) : [];
};
const hashtags = parseHashtags(item.content);
const handleConvertToTask = () => {
// Convert hashtags to Tag objects
const taskTags = hashtags.map(hashtagName => {
const taskTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName };
});
@ -56,7 +65,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
name: item.content,
status: 'not_started',
priority: 'medium',
tags: taskTags
tags: taskTags,
};
if (item.id !== undefined) {
@ -68,9 +77,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
const handleConvertToProject = () => {
// Convert hashtags to Tag objects
const projectTags = hashtags.map(hashtagName => {
const projectTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName };
});
@ -78,7 +89,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
name: item.content,
description: '',
active: true,
tags: projectTags
tags: projectTags,
};
if (item.id !== undefined) {
@ -89,25 +100,32 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
};
const handleConvertToNote = async () => {
let title = item.content.split('\n')[0] || item.content.substring(0, 50);
let title =
item.content.split('\n')[0] || item.content.substring(0, 50);
let content = item.content;
let isBookmark = false;
try {
const { isUrl, extractUrlTitle } = await import("../../utils/urlService");
const { isUrl, extractUrlTitle } = await import(
'../../utils/urlService'
);
if (isUrl(item.content.trim())) {
setLoading(true);
try {
// Add a timeout to prevent infinite loading
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000) // 10 second timeout
const timeoutPromise = new Promise(
(_, reject) =>
setTimeout(
() => reject(new Error('Timeout')),
10000
) // 10 second timeout
);
const result = await Promise.race([
const result = (await Promise.race([
extractUrlTitle(item.content.trim()),
timeoutPromise
]) as any;
timeoutPromise,
])) as any;
if (result && result.title) {
title = result.title;
@ -115,7 +133,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
isBookmark = true;
}
} catch (titleError) {
console.error("Error extracting URL title:", titleError);
console.error('Error extracting URL title:', titleError);
// Continue with default title if URL title extraction fails
// Still mark as bookmark if it's a URL
isBookmark = true;
@ -124,25 +142,27 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
}
}
} catch (error) {
console.error("Error checking URL or extracting title:", error);
console.error('Error checking URL or extracting title:', error);
setLoading(false);
}
// Convert hashtags to Tag objects and include bookmark tag if needed
const hashtagTags = hashtags.map(hashtagName => {
const hashtagTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName };
});
// Combine hashtag tags with bookmark tag if it's a URL
const bookmarkTag = isBookmark ? [{ name: "bookmark" }] : [];
const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : [];
const tagObjects = [...hashtagTags, ...bookmarkTag];
const newNote: Note = {
title: title,
content: content,
tags: tagObjects
tags: tagObjects,
};
if (item.id !== undefined) {
@ -152,7 +172,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
}
};
const handleDelete = () => {
setShowConfirmDialog(true);
};
@ -241,7 +260,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{showConfirmDialog && (
<ConfirmDialog
title={t('inbox.deleteConfirmTitle', 'Delete Item')}
message={t('inbox.deleteConfirmMessage', 'Are you sure you want to delete this inbox item? This action cannot be undone.')}
message={t(
'inbox.deleteConfirmMessage',
'Are you sure you want to delete this inbox item? This action cannot be undone.'
)}
onConfirm={confirmDelete}
onCancel={() => setShowConfirmDialog(false)}
/>

View file

@ -6,7 +6,7 @@ import {
loadInboxItemsToStore,
processInboxItemWithStore,
deleteInboxItemWithStore,
updateInboxItemWithStore
updateInboxItemWithStore,
} from '../../utils/inboxService';
import InboxItemDetail from './InboxItemDetail';
import { useToast } from '../Shared/ToastContext';
@ -29,7 +29,7 @@ const InboxItems: React.FC = () => {
const { showSuccessToast, showErrorToast } = useToast();
// Access store data
const { inboxItems, isLoading } = useStore(state => state.inboxStore);
const { inboxItems, isLoading } = useStore((state) => state.inboxStore);
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
@ -43,7 +43,9 @@ const InboxItems: React.FC = () => {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
// Track the current inbox item ID being converted (for task/project/note conversion)
const [currentConversionItemId, setCurrentConversionItemId] = useState<number | null>(null);
const [currentConversionItemId, setCurrentConversionItemId] = useState<
number | null
>(null);
// Track the current inbox item being edited
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
@ -69,19 +71,33 @@ const InboxItems: React.FC = () => {
};
// Handler for the inboxItemsUpdated custom event
const handleInboxItemsUpdated = (event: CustomEvent<{count: number, firstItemContent: string}>) => {
const handleInboxItemsUpdated = (
event: CustomEvent<{ count: number; firstItemContent: string }>
) => {
// Show toast notifications for new items
if (event.detail.count > 0) {
// Show notification for the first new item
showSuccessToast(t('inbox.newTelegramItem', 'New item from Telegram: {{content}}', {
content: event.detail.firstItemContent
}));
showSuccessToast(
t(
'inbox.newTelegramItem',
'New item from Telegram: {{content}}',
{
content: event.detail.firstItemContent,
}
)
);
// If multiple new items, show a summary notification as well
if (event.detail.count > 1) {
showSuccessToast(t('inbox.multipleNewItems', '{{count}} more new items added', {
count: event.detail.count - 1
}));
showSuccessToast(
t(
'inbox.multipleNewItems',
'{{count}} more new items added',
{
count: event.detail.count - 1,
}
)
);
}
}
};
@ -94,12 +110,18 @@ const InboxItems: React.FC = () => {
// Add event listeners
window.addEventListener('forceInboxReload', handleForceReload);
window.addEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
window.addEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
return () => {
clearInterval(pollInterval);
window.removeEventListener('forceInboxReload', handleForceReload);
window.removeEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
window.removeEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
};
}, [refreshInboxItems]);
@ -165,7 +187,10 @@ const InboxItems: React.FC = () => {
setIsTaskModalOpen(true);
};
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => {
const handleOpenProjectModal = (
project: Project | null,
inboxItemId?: number
) => {
setProjectToEdit(project);
if (inboxItemId) {
@ -175,7 +200,10 @@ const InboxItems: React.FC = () => {
setIsProjectModalOpen(true);
};
const handleOpenNoteModal = async (note: Note | null, inboxItemId?: number) => {
const handleOpenNoteModal = async (
note: Note | null,
inboxItemId?: number
) => {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
@ -191,7 +219,7 @@ const InboxItems: React.FC = () => {
if (note && note.content && isUrl(note.content.trim())) {
if (!note.tags) {
note.tags = [{ name: 'bookmark' }];
} else if (!note.tags.some(tag => tag.name === 'bookmark')) {
} else if (!note.tags.some((tag) => tag.name === 'bookmark')) {
note.tags.push({ name: 'bookmark' });
}
}
@ -210,7 +238,14 @@ const InboxItems: React.FC = () => {
const createdTask = await createTask(task);
const taskLink = (
<span>
{t('task.created', 'Task')} <a href={`/task/${createdTask.uuid}`} className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
{t('task.created', 'Task')}{' '}
<a
href={`/task/${createdTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{createdTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
@ -246,7 +281,6 @@ const InboxItems: React.FC = () => {
}
};
const handleSaveNote = async (note: Note) => {
try {
// Check if the content appears to be a URL and add the bookmark tag
@ -259,14 +293,19 @@ const InboxItems: React.FC = () => {
}
// Add a bookmark tag if content is a URL and doesn't already have the tag
if (isBookmarkContent && !note.tags.some(tag => tag.name === 'bookmark')) {
if (
isBookmarkContent &&
!note.tags.some((tag) => tag.name === 'bookmark')
) {
// Use spread operator to create a new array with the bookmark tag added
note.tags = [...note.tags, { name: 'bookmark' }];
}
// Create the note with proper tags
await createNote(note);
showSuccessToast(t('note.createSuccess', 'Note created successfully'));
showSuccessToast(
t('note.createSuccess', 'Note created successfully')
);
// Process the inbox item after successful note creation
if (currentConversionItemId !== null) {
@ -315,7 +354,10 @@ const InboxItems: React.FC = () => {
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{t('taskViews.inbox', 'Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don\'t have a due date will appear here. This is your \'brain dump\' area where you can quickly note down tasks and organize them later.')}
{t(
'taskViews.inbox',
"Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don't have a due date will appear here. This is your 'brain dump' area where you can quickly note down tasks and organize them later."
)}
</p>
<div className="space-y-2">
@ -344,7 +386,13 @@ const InboxItems: React.FC = () => {
setIsTaskModalOpen(false);
setTaskToEdit(null);
}}
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }}
task={
taskToEdit || {
name: '',
status: 'not_started',
priority: 'medium',
}
}
onSave={handleSaveTask}
onDelete={async () => {}} // No need to delete since it's a new task
projects={Array.isArray(projects) ? projects : []}
@ -358,7 +406,8 @@ const InboxItems: React.FC = () => {
})()}
{/* Project Modal - Only render when needed to prevent infinite loops */}
{isProjectModalOpen && (() => {
{isProjectModalOpen &&
(() => {
try {
return (
<ProjectModal
@ -409,7 +458,10 @@ const InboxItems: React.FC = () => {
setItemToEdit(null);
}}
onSave={async () => {}} // Not used in edit mode
initialText={inboxItems.find(item => item.id === itemToEdit)?.content || ""}
initialText={
inboxItems.find((item) => item.id === itemToEdit)
?.content || ''
}
editMode={true}
onEdit={handleSaveEditedItem}
/>

View file

@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Task } from "../../entities/Task";
import { Tag } from "../../entities/Tag";
import { useToast } from "../Shared/ToastContext";
import { useTranslation } from "react-i18next";
import { createInboxItemWithStore } from "../../utils/inboxService";
import { isAuthError } from "../../utils/authUtils";
import { createTag } from "../../utils/tagsService";
import { XMarkIcon, TagIcon } from "@heroicons/react/24/outline";
import { useStore } from "../../store/useStore";
import { Link } from "react-router-dom";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Task } from '../../entities/Task';
import { Tag } from '../../entities/Tag';
import { useToast } from '../Shared/ToastContext';
import { useTranslation } from 'react-i18next';
import { createInboxItemWithStore } from '../../utils/inboxService';
import { isAuthError } from '../../utils/authUtils';
import { createTag } from '../../utils/tagsService';
import { XMarkIcon, TagIcon } from '@heroicons/react/24/outline';
import { useStore } from '../../store/useStore';
import { Link } from 'react-router-dom';
// import UrlPreview from "../Shared/UrlPreview";
// import { UrlTitleResult } from "../../utils/urlService";
@ -25,7 +25,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
isOpen,
onClose,
onSave,
initialText = "",
initialText = '',
editMode = false,
onEdit,
}) => {
@ -37,12 +37,17 @@ const InboxModal: React.FC<InboxModalProps> = ({
const { showSuccessToast, showErrorToast } = useToast();
const nameInputRef = useRef<HTMLInputElement>(null);
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
const { tagsStore: { tags, setTags } } = useStore();
const {
tagsStore: { tags, setTags },
} = useStore();
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
const [cursorPosition, setCursorPosition] = useState(0);
const [currentHashtagQuery, setCurrentHashtagQuery] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0 });
const [, setCurrentHashtagQuery] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({
left: 0,
top: 0,
});
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
// Dispatch global modal events to hide floating + button
@ -51,7 +56,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
const parseHashtags = (text: string): string[] => {
const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
const matches = text.match(hashtagRegex);
return matches ? matches.map(tag => tag.substring(1)) : [];
return matches ? matches.map((tag) => tag.substring(1)) : [];
};
// Helper function to get current hashtag query at cursor position
@ -62,31 +67,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
};
// Helper function to render text with clickable hashtags
const renderTextWithHashtags = (text: string) => {
const parts = text.split(/(#[a-zA-Z0-9_]+)/g);
return parts.map((part, index) => {
if (part.startsWith('#')) {
const tagName = part.substring(1);
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
return (
<Link
key={index}
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{part}
</Link>
);
}
}
return <span key={index}>{part}</span>;
});
};
// const renderTextWithHashtags = (text: string) => {
// const parts = text.split(/(#[a-zA-Z0-9_]+)/g);
// return parts.map((part, index) => {
// if (part.startsWith('#')) {
// const tagName = part.substring(1);
// const tag = tags.find(
// (t) => t.name.toLowerCase() === tagName.toLowerCase()
// );
// if (tag) {
// return (
// <Link
// key={index}
// to={`/tag/${encodeURIComponent(tag.name)}`}
// className="text-blue-600 dark:text-blue-400 hover:underline"
// onClick={(e) => e.stopPropagation()}
// >
// {part}
// </Link>
// );
// }
// }
// return <span key={index}>{part}</span>;
// });
// };
// Helper function to calculate dropdown position based on cursor
const calculateDropdownPosition = (input: HTMLInputElement, cursorPos: number) => {
const calculateDropdownPosition = (
input: HTMLInputElement,
cursorPos: number
) => {
// Create a temporary element to measure text width
const temp = document.createElement('span');
temp.style.visibility = 'hidden';
@ -122,7 +132,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
return {
left: hashtagOffset,
top: input.offsetHeight
top: input.offsetHeight,
};
}
@ -162,12 +172,19 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) {
// Filter tags based on current query
const filtered = tags.filter(tag =>
tag.name.toLowerCase().startsWith(hashtagQuery.toLowerCase())
).slice(0, 5); // Limit to 5 suggestions
const filtered = tags
.filter((tag) =>
tag.name
.toLowerCase()
.startsWith(hashtagQuery.toLowerCase())
)
.slice(0, 5); // Limit to 5 suggestions
// Calculate dropdown position
const position = calculateDropdownPosition(e.target, newCursorPosition);
const position = calculateDropdownPosition(
e.target,
newCursorPosition
);
setDropdownPosition(position);
setFilteredTags(filtered);
@ -185,7 +202,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/);
if (hashtagMatch) {
const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + afterCursor;
const newText =
beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) +
afterCursor;
setInputText(newText);
setShowTagSuggestions(false);
setFilteredTags([]);
@ -194,8 +213,14 @@ const InboxModal: React.FC<InboxModalProps> = ({
setTimeout(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
const newCursorPos = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`).length;
nameInputRef.current.setSelectionRange(newCursorPos, newCursorPos);
const newCursorPos = beforeCursor.replace(
/#([a-zA-Z0-9_]*)$/,
`#${tagName}`
).length;
nameInputRef.current.setSelectionRange(
newCursorPos,
newCursorPos
);
}
}, 0);
}
@ -204,9 +229,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
// Create missing tags automatically
const createMissingTags = async (text: string): Promise<void> => {
const hashtagsInText = parseHashtags(text);
const existingTagNames = tags.map(tag => tag.name.toLowerCase());
const missingTags = hashtagsInText.filter(tagName =>
!existingTagNames.includes(tagName.toLowerCase())
const existingTagNames = tags.map((tag) => tag.name.toLowerCase());
const missingTags = hashtagsInText.filter(
(tagName) => !existingTagNames.includes(tagName.toLowerCase())
);
for (const tagName of missingTags) {
@ -243,7 +268,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (saveMode === 'task') {
const newTask: Task = {
name: inputText.trim(),
status: "not_started",
status: 'not_started',
};
try {
@ -276,19 +301,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (editMode) {
showErrorToast(t('inbox.updateError'));
} else {
showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError'));
showErrorToast(
saveMode === 'task'
? t('task.createError')
: t('inbox.addError')
);
}
} finally {
setIsSaving(false);
}
}, [inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose, tags, setTags]);
}, [
inputText,
isSaving,
editMode,
onEdit,
saveMode,
onSave,
showSuccessToast,
showErrorToast,
t,
onClose,
tags,
setTags,
]);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
if (!editMode) {
setInputText("");
setInputText('');
setSaveMode('inbox');
}
setIsClosing(false);
@ -297,7 +339,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
if (showTagSuggestions) {
setShowTagSuggestions(false);
setFilteredTags([]);
@ -307,16 +352,16 @@ const InboxModal: React.FC<InboxModalProps> = ({
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, showTagSuggestions, handleClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === 'Escape') {
if (showTagSuggestions) {
setShowTagSuggestions(false);
setFilteredTags([]);
@ -326,10 +371,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, showTagSuggestions, handleClose]);
@ -338,13 +383,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
return (
<div
className={`fixed top-16 left-0 right-0 bottom-0 sm:top-16 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-[45] transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100"
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div
ref={modalRef}
className={`relative bg-white dark:bg-gray-800 border-0 sm:border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full h-full sm:h-auto sm:max-w-2xl md:max-w-3xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
isClosing ? 'scale-95' : 'scale-100'
} flex flex-col`}
>
{/* Close button - only visible on mobile */}
@ -367,29 +412,44 @@ const InboxModal: React.FC<InboxModalProps> = ({
value={inputText}
onChange={handleChange}
onSelect={(e) => {
const pos = e.currentTarget.selectionStart || 0;
const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos);
// Update dropdown position if showing suggestions
if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos);
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onKeyUp={(e) => {
const pos = e.currentTarget.selectionStart || 0;
const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos);
// Update dropdown position if showing suggestions
if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos);
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
onClick={(e) => {
const pos = e.currentTarget.selectionStart || 0;
const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos);
// Update dropdown position if showing suggestions
if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos);
const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position);
}
}}
@ -397,7 +457,12 @@ const InboxModal: React.FC<InboxModalProps> = ({
className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2"
placeholder={t('inbox.captureThought')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !isSaving && !showTagSuggestions) {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!isSaving &&
!showTagSuggestions
) {
e.preventDefault();
handleSubmit();
}
@ -405,54 +470,85 @@ const InboxModal: React.FC<InboxModalProps> = ({
/>
{/* Tags display like TaskItem */}
{inputText && parseHashtags(inputText).length > 0 && (
{inputText &&
parseHashtags(inputText).length > 0 && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
<TagIcon className="h-3 w-3 mr-1" />
<span>
{parseHashtags(inputText).map((tagName, index) => {
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase());
const isLast = index === parseHashtags(inputText).length - 1;
{parseHashtags(inputText).map(
(tagName, index) => {
const tag = tags.find(
(t) =>
t.name.toLowerCase() ===
tagName.toLowerCase()
);
const isLast =
index ===
parseHashtags(
inputText
).length -
1;
if (tag) {
return (
<span key={index}>
<span
key={index}
>
<Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={(e) => e.stopPropagation()}
onClick={(
e
) =>
e.stopPropagation()
}
>
{tagName}
{
tagName
}
</Link>
{!isLast && ', '}
{!isLast &&
', '}
</span>
);
} else {
return (
<span key={index} className="text-orange-500 dark:text-orange-400">
{tagName}{!isLast && ', '}
<span
key={index}
className="text-orange-500 dark:text-orange-400"
>
{tagName}
{!isLast &&
', '}
</span>
);
}
})}
}
)}
</span>
</div>
)}
{/* Tag Suggestions Dropdown */}
{showTagSuggestions && filteredTags.length > 0 && (
{showTagSuggestions &&
filteredTags.length > 0 && (
<div
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
style={{
left: `${dropdownPosition.left}px`,
top: `${dropdownPosition.top + 4}px`,
minWidth: '120px',
maxWidth: '200px'
maxWidth: '200px',
}}
>
{filteredTags.map((tag, index) => (
<button
key={tag.id || index}
onClick={() => handleTagSelect(tag.name)}
onClick={() =>
handleTagSelect(
tag.name
)
}
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md"
>
#{tag.name}
@ -467,11 +563,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
disabled={!inputText.trim() || isSaving}
className={`mt-4 sm:mt-0 sm:ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
inputText.trim() && !isSaving
? "bg-blue-600 hover:bg-blue-700"
: "bg-blue-400 cursor-not-allowed"
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-400 cursor-not-allowed'
}`}
>
{isSaving ? t('common.saving') : t('common.save')}
{isSaving
? t('common.saving')
: t('common.save')}
</button>
</div>
{/* URL Preview disabled */}

View file

@ -20,7 +20,7 @@ const Login: React.FC = () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
credentials: 'include'
credentials: 'include',
});
const data = await response.json();
@ -30,7 +30,9 @@ const Login: React.FC = () => {
await i18n.changeLanguage(data.user.language);
}
window.dispatchEvent(new CustomEvent('userLoggedIn', { detail: data.user }));
window.dispatchEvent(
new CustomEvent('userLoggedIn', { detail: data.user })
);
navigate('/today');
} else {
@ -44,14 +46,10 @@ const Login: React.FC = () => {
return (
<div className="bg-gray-100 flex flex-col items-center justify-center min-h-screen px-4">
<h1 className="text-5xl font-bold text-gray-300 mb-6">
tududi
</h1>
<h1 className="text-5xl font-bold text-gray-300 mb-6">tududi</h1>
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
{error && (
<div className="mb-4 text-center text-red-500">
{error}
</div>
<div className="mb-4 text-center text-red-500">{error}</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">

View file

@ -1,8 +1,13 @@
import React, { useState, useRef, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UserIcon, Bars3Icon, BoltIcon, InboxIcon } from "@heroicons/react/24/solid";
import { useTranslation } from "react-i18next";
import PomodoroTimer from "./Shared/PomodoroTimer";
import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
UserIcon,
Bars3Icon,
BoltIcon,
InboxIcon,
} from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import PomodoroTimer from './Shared/PomodoroTimer';
interface NavbarProps {
isDarkMode: boolean;
@ -18,8 +23,6 @@ interface NavbarProps {
}
const Navbar: React.FC<NavbarProps> = ({
isDarkMode,
toggleDarkMode,
currentUser,
setCurrentUser,
isSidebarOpen,
@ -41,9 +44,9 @@ const Navbar: React.FC<NavbarProps> = ({
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
@ -52,11 +55,15 @@ const Navbar: React.FC<NavbarProps> = ({
const fetchProfile = async () => {
try {
const response = await fetch('/api/profile', {
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
const profile = await response.json();
setPomodoroEnabled(profile.pomodoro_enabled !== undefined ? profile.pomodoro_enabled : true);
setPomodoroEnabled(
profile.pomodoro_enabled !== undefined
? profile.pomodoro_enabled
: true
);
}
} catch (error) {
console.error('Error fetching profile:', error);
@ -71,10 +78,16 @@ const Navbar: React.FC<NavbarProps> = ({
setPomodoroEnabled(event.detail.enabled);
};
window.addEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
window.addEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
return () => {
window.removeEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
window.removeEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
};
}, []);
@ -107,7 +120,11 @@ const Navbar: React.FC<NavbarProps> = ({
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="flex items-center focus:outline-none text-gray-500 dark:text-gray-500"
aria-label={isSidebarOpen ? "Collapse Sidebar" : "Expand Sidebar"}
aria-label={
isSidebarOpen
? 'Collapse Sidebar'
: 'Expand Sidebar'
}
>
<Bars3Icon className="h-6 mt-1 w-6 mr-2" />
</button>
@ -160,7 +177,10 @@ const Navbar: React.FC<NavbarProps> = ({
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setIsDropdownOpen(false)}
>
{t('navigation.profileSettings', 'Profile Settings')}
{t(
'navigation.profileSettings',
'Profile Settings'
)}
</Link>
<Link
to="/about"

View file

@ -1,17 +1,27 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
import {
PencilSquareIcon,
TrashIcon,
TagIcon,
DocumentTextIcon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from '../Shared/ConfirmDialog';
import NoteModal from './NoteModal';
import MarkdownRenderer from '../Shared/MarkdownRenderer';
import { Note } from '../../entities/Note';
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService';
import {
fetchNotes,
deleteNote as apiDeleteNote,
updateNote as apiUpdateNote,
} from '../../utils/notesService';
const NoteDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [note, setNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
@ -52,10 +62,13 @@ const NoteDetails: React.FC = () => {
const handleSaveNote = async (updatedNote: Note) => {
try {
if (updatedNote.id !== undefined) {
const savedNote = await apiUpdateNote(updatedNote.id, updatedNote);
const savedNote = await apiUpdateNote(
updatedNote.id,
updatedNote
);
setNote(savedNote);
} else {
console.error("Error: Note ID is undefined.");
console.error('Error: Note ID is undefined.');
}
} catch (err) {
console.error('Error saving note:', err);
@ -86,7 +99,9 @@ const NoteDetails: React.FC = () => {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">
{isError ? 'Error loading note details.' : 'Note not found.'}
{isError
? 'Error loading note details.'
: 'Note not found.'}
</div>
</div>
);
@ -124,26 +139,38 @@ const NoteDetails: React.FC = () => {
</div>
</div>
{/* Tags and Project */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) || note.project || note.Project ? (
{(note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0) ||
note.project ||
note.Project ? (
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
{/* Note Tags */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
{((note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0)) && (
<div className="mb-4">
<div className="flex items-start">
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" />
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Tags:
</span>
<div className="flex flex-wrap gap-2 mt-1">
{(note.tags || note.Tags || []).map((tag) => (
{(note.tags || note.Tags || []).map(
(tag) => (
<button
key={tag.id}
onClick={() => navigate(`/tag/${encodeURIComponent(tag.name)}`)}
onClick={() =>
navigate(
`/tag/${encodeURIComponent(tag.name)}`
)
}
className="flex items-center space-x-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors text-xs"
>
<TagIcon className="h-3 w-3" />
<span>{tag.name}</span>
</button>
))}
)
)}
</div>
</div>
</div>
@ -151,7 +178,14 @@ const NoteDetails: React.FC = () => {
)}
{/* Note Project */}
{(note.project || note.Project) && (
<div className={((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) ? "mt-4" : ""}>
<div
className={
(note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0)
? 'mt-4'
: ''
}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Project
</h3>

View file

@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { Note } from '../../entities/Note';
import { Project } from '../../entities/Project';
import { useToast } from '../Shared/ToastContext';
@ -7,7 +13,13 @@ import MarkdownRenderer from '../Shared/MarkdownRenderer';
import { Tag } from '../../entities/Tag';
import { fetchTags } from '../../utils/tagsService';
import { useTranslation } from 'react-i18next';
import { EyeIcon, PencilIcon, FolderIcon, TagIcon, TrashIcon } from '@heroicons/react/24/outline';
import {
EyeIcon,
PencilIcon,
FolderIcon,
TagIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
interface NoteModalProps {
isOpen: boolean;
@ -19,7 +31,15 @@ interface NoteModalProps {
onCreateProject?: (name: string) => Promise<Project>;
}
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, onDelete, projects = [], onCreateProject }) => {
const NoteModal: React.FC<NoteModalProps> = ({
isOpen,
onClose,
note,
onSave,
onDelete,
projects = [],
onCreateProject,
}) => {
const { t } = useTranslation();
const [formData, setFormData] = useState<Note>({
id: note?.id,
@ -27,7 +47,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
content: note?.content || '',
tags: note?.tags || [],
});
const [tags, setTags] = useState<string[]>(note?.tags?.map((tag) => tag.name) || []);
const [tags, setTags] = useState<string[]>(
note?.tags?.map((tag) => tag.name) || []
);
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
@ -37,7 +59,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
// Project-related state
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const [newProjectName, setNewProjectName] = useState<string>("");
const [newProjectName, setNewProjectName] = useState<string>('');
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
@ -88,7 +110,10 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
setError(null);
// Initialize project name from note - exactly like TaskModal
const currentProject = memoizedProjects.find((project) => project.id === (note?.project?.id || note?.Project?.id));
const currentProject = memoizedProjects.find(
(project) =>
project.id === (note?.project?.id || note?.Project?.id)
);
setNewProjectName(currentProject ? currentProject.name : '');
}
}, [isOpen, note, memoizedProjects]);
@ -152,21 +177,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
}));
}, []);
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
setExpandedSections(prev => {
const toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = {
...prev,
[section]: !prev[section]
[section]: !prev[section],
};
// Auto-scroll to show the expanded section
if (newExpanded[section]) {
setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth'
behavior: 'smooth',
});
}
}, 100); // Small delay to ensure DOM is updated
@ -174,7 +202,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
return newExpanded;
});
}, []);
},
[]
);
const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
@ -194,24 +224,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
};
const handleProjectSelection = (project: Project) => {
setFormData(prev => ({
setFormData((prev) => ({
...prev,
project: { id: project.id!, name: project.name },
project_id: project.id
project_id: project.id,
}));
setNewProjectName(project.name);
setDropdownOpen(false);
};
const handleCreateProject = async () => {
if (newProjectName.trim() !== "" && onCreateProject) {
if (newProjectName.trim() !== '' && onCreateProject) {
setIsCreatingProject(true);
try {
const newProject = await onCreateProject(newProjectName.trim());
setFormData(prev => ({
setFormData((prev) => ({
...prev,
project: { id: newProject.id!, name: newProject.name },
project_id: newProject.id
project_id: newProject.id,
}));
setFilteredProjects([...filteredProjects, newProject]);
setNewProjectName(newProject.name);
@ -219,7 +249,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
showSuccessToast(t('success.projectCreated'));
} catch (error) {
showErrorToast(t('errors.projectCreationFailed'));
console.error("Error creating project:", error);
console.error('Error creating project:', error);
} finally {
setIsCreatingProject(false);
}
@ -237,13 +267,17 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
try {
// Convert string tags to tag objects
const noteTags: Tag[] = tags.map(tagName => ({ name: tagName }));
const noteTags: Tag[] = tags.map((tagName) => ({ name: tagName }));
// Create final form data with the tags
const finalFormData = { ...formData, tags: noteTags };
await onSave(finalFormData);
showSuccessToast(formData.id && formData.id !== 0 ? t('success.noteUpdated') : t('success.noteCreated'));
showSuccessToast(
formData.id && formData.id !== 0
? t('success.noteUpdated')
: t('success.noteCreated')
);
handleClose();
} catch (err) {
setError((err as Error).message);
@ -272,21 +306,26 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100"
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="h-full flex items-start justify-center sm:px-4 sm:py-4">
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[600px] sm:max-h-[90vh]">
{/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}>
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{
WebkitOverflowScrolling: 'touch',
}}
>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Note Title Section - Always Visible */}
@ -299,7 +338,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
onChange={handleChange}
required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.noteTitlePlaceholder')}
placeholder={t(
'forms.noteTitlePlaceholder'
)}
/>
</div>
@ -307,32 +348,52 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('forms.noteContent')} <span className="text-gray-500">(Markdown supported)</span>
{t(
'forms.noteContent'
)}{' '}
<span className="text-gray-500">
(Markdown
supported)
</span>
</label>
<div className="flex space-x-1">
<button
type="button"
onClick={() => setActiveTab('edit')}
onClick={() =>
setActiveTab(
'edit'
)
}
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
activeTab === 'edit'
activeTab ===
'edit'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
<PencilIcon className="h-3 w-3" />
<span>Edit</span>
<span>
Edit
</span>
</button>
<button
type="button"
onClick={() => setActiveTab('preview')}
onClick={() =>
setActiveTab(
'preview'
)
}
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
activeTab === 'preview'
activeTab ===
'preview'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
<EyeIcon className="h-3 w-3" />
<span>Preview</span>
<span>
Preview
</span>
</button>
</div>
</div>
@ -341,19 +402,39 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<textarea
id="noteContent"
name="content"
value={formData.content}
onChange={handleChange}
value={
formData.content
}
onChange={
handleChange
}
rows={15}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out resize-none"
placeholder="Write your content using Markdown formatting...&#10;&#10;Examples:&#10;# Heading&#10;**Bold text**&#10;*Italic text*&#10;- List item&#10;```code```"
/>
) : (
<div className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-gray-50 dark:bg-gray-800 overflow-y-auto" style={{ minHeight: '300px', maxHeight: '400px' }}>
<div
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-gray-50 dark:bg-gray-800 overflow-y-auto"
style={{
minHeight:
'300px',
maxHeight:
'400px',
}}
>
{formData.content ? (
<MarkdownRenderer content={formData.content} />
<MarkdownRenderer
content={
formData.content
}
/>
) : (
<p className="text-gray-500 dark:text-gray-400 italic">
No content to preview. Switch to Edit tab to add content.
No content
to preview.
Switch to
Edit tab to
add content.
</p>
)}
</div>
@ -367,9 +448,13 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{t('forms.tags')}
</h3>
<TagInput
onTagsChange={handleTagsChange}
onTagsChange={
handleTagsChange
}
initialTags={tags}
availableTags={availableTags}
availableTags={
availableTags
}
/>
</div>
)}
@ -377,45 +462,84 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{expandedSections.project && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.project', 'Project')}
{t(
'forms.task.labels.project',
'Project'
)}
</h3>
<div className="relative">
<input
type="text"
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
value={newProjectName}
onChange={handleProjectSearch}
placeholder={t(
'forms.task.projectSearchPlaceholder',
'Search or create a project...'
)}
value={
newProjectName
}
onChange={
handleProjectSearch
}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
/>
{dropdownOpen && newProjectName && (
{dropdownOpen &&
newProjectName && (
<div className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700">
{filteredProjects.length > 0 && (
filteredProjects.map((project) => (
{filteredProjects.length >
0 &&
filteredProjects.map(
(
project
) => (
<button
key={project.id}
key={
project.id
}
type="button"
onClick={() => handleProjectSelection(project)}
onClick={() =>
handleProjectSelection(
project
)
}
className="block w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{project.name}
{
project.name
}
</button>
))
)
)}
{filteredProjects.length === 0 && (
{filteredProjects.length ===
0 && (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t('forms.task.noMatchingProjects', 'No matching projects')}
{t(
'forms.task.noMatchingProjects',
'No matching projects'
)}
</div>
)}
{newProjectName.trim() && onCreateProject && (
{newProjectName.trim() &&
onCreateProject && (
<button
type="button"
onClick={handleCreateProject}
disabled={isCreatingProject}
onClick={
handleCreateProject
}
disabled={
isCreatingProject
}
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors"
>
{isCreatingProject
? t('forms.task.creatingProject', 'Creating...')
: t('forms.task.createProject', '+ Create') + ` "${newProjectName.trim()}"`}
? t(
'forms.task.creatingProject',
'Creating...'
)
: t(
'forms.task.createProject',
'+ Create'
) +
` "${newProjectName.trim()}"`}
</button>
)}
</div>
@ -424,7 +548,11 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
</div>
)}
{error && <div className="text-red-500 px-4 mb-4">{error}</div>}
{error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset>
</form>
</div>
@ -438,7 +566,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{/* Tags Toggle */}
<button
type="button"
onClick={() => toggleSection('tags')}
onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -447,7 +577,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
title={t('forms.tags', 'Tags')}
>
<TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && (
{formData.tags &&
formData.tags.length >
0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
@ -455,13 +587,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{/* Project Toggle */}
<button
type="button"
onClick={() => toggleSection('project')}
onClick={() =>
toggleSection('project')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.project
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.task.labels.project', 'Project')}
title={t(
'forms.task.labels.project',
'Project'
)}
>
<FolderIcon className="h-5 w-5" />
{formData.project && (
@ -476,12 +613,15 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{(note && note.id && onDelete) && (
{note && note.id && onDelete && (
<button
type="button"
onClick={handleDeleteNote}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
@ -501,7 +641,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
onClick={handleSubmit}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isSubmitting

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
BookOpenIcon,
@ -25,7 +25,6 @@ const Notes: React.FC = () => {
console.log('Notes component rendering...');
const { t } = useTranslation();
const navigate = useNavigate();
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
@ -41,8 +40,13 @@ const Notes: React.FC = () => {
// Memoize projects to ensure stable reference
const memoizedProjects = useMemo(() => projects || [], [projects]);
console.log('Notes component render - projects:', { projectsLength: projects?.length, projects: projects?.map(p => p.name) });
console.log('Memoized projects:', { memoizedLength: memoizedProjects?.length });
console.log('Notes component render - projects:', {
projectsLength: projects?.length,
projects: projects?.map((p) => p.name),
});
console.log('Memoized projects:', {
memoizedLength: memoizedProjects?.length,
});
const [isError, setIsError] = useState(false);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
@ -67,13 +71,20 @@ const Notes: React.FC = () => {
// Load projects if not available - force load every time for debugging
useEffect(() => {
const loadProjectsIfNeeded = async () => {
console.log('useEffect triggered - projects length:', projects?.length);
console.log(
'useEffect triggered - projects length:',
projects?.length
);
console.log('Force loading projects in Notes component...');
try {
// Fetch all projects (active and inactive)
const fetchedProjects = await fetchProjects("all", "");
const fetchedProjects = await fetchProjects('all', '');
console.log('Raw API response:', fetchedProjects);
console.log('Projects loaded:', fetchedProjects.length, fetchedProjects.map(p => p.name));
console.log(
'Projects loaded:',
fetchedProjects.length,
fetchedProjects.map((p) => p.name)
);
setProjects(fetchedProjects);
console.log('setProjects called');
} catch (error) {
@ -88,7 +99,9 @@ const Notes: React.FC = () => {
if (!noteToDelete) return;
try {
await apiDeleteNote(noteToDelete.id!);
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id));
setNotes((prev) =>
prev.filter((note) => note.id !== noteToDelete.id)
);
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
} catch (err) {
@ -101,7 +114,7 @@ const Notes: React.FC = () => {
projectsLength: projects?.length,
memoizedLength: memoizedProjects?.length,
projectsExist: !!projects,
memoizedExist: !!memoizedProjects
memoizedExist: !!memoizedProjects,
});
setSelectedNote(note);
setIsNoteModalOpen(true);
@ -129,7 +142,10 @@ const Notes: React.FC = () => {
const handleCreateProject = async (name: string) => {
try {
const newProject = await createProject({ name, priority: 'medium' });
const newProject = await createProject({
name,
priority: 'medium',
});
return newProject;
} catch (error) {
console.error('Error creating project:', error);
@ -143,7 +159,6 @@ const Notes: React.FC = () => {
note.content.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
@ -191,14 +206,18 @@ const Notes: React.FC = () => {
{/* Notes List */}
{filteredNotes.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">{t('notes.noNotesFound')}</p>
<p className="text-gray-700 dark:text-gray-300">
{t('notes.noNotesFound')}
</p>
) : (
<ul className="space-y-1">
{filteredNotes.map((note) => (
<li
key={note.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
onMouseEnter={() => setHoveredNoteId(note.id || null)}
onMouseEnter={() =>
setHoveredNoteId(note.id || null)
}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
@ -210,21 +229,57 @@ const Notes: React.FC = () => {
{note.title}
</Link>
{/* Project and Tags */}
{((note.project || note.Project) || ((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0))) && (
{(note.project ||
note.Project ||
(note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length > 0)) && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
{(note.project || note.Project) && (
{(note.project ||
note.Project) && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>{(note.project || note.Project)?.name}</span>
<span>
{
(
note.project ||
note.Project
)?.name
}
</span>
</div>
)}
{(note.project || note.Project) && ((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
<span className="mx-2"></span>
{(note.project ||
note.Project) &&
((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length >
0)) && (
<span className="mx-2">
</span>
)}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
{((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length >
0)) && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
<span>{(note.tags || note.Tags || []).map(tag => tag.name).join(', ')}</span>
<span>
{(
note.tags ||
note.Tags ||
[]
)
.map(
(tag) =>
tag.name
)
.join(', ')}
</span>
</div>
)}
</div>
@ -235,8 +290,13 @@ const Notes: React.FC = () => {
<button
onClick={() => handleEditNote(note)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })}
title={t('notes.editNoteTitle', { noteTitle: note.title })}
aria-label={t(
'notes.editNoteAriaLabel',
{ noteTitle: note.title }
)}
title={t('notes.editNoteTitle', {
noteTitle: note.title,
})}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
@ -246,8 +306,13 @@ const Notes: React.FC = () => {
setIsConfirmDialogOpen(true);
}}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })}
title={t('notes.deleteNoteTitle', { noteTitle: note.title })}
aria-label={t(
'notes.deleteNoteAriaLabel',
{ noteTitle: note.title }
)}
title={t('notes.deleteNoteTitle', {
noteTitle: note.title,
})}
>
<TrashIcon className="h-5 w-5" />
</button>
@ -262,14 +327,18 @@ const Notes: React.FC = () => {
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
console.log('Closing modal, projects at close:', { projectsLength: projects?.length });
console.log('Closing modal, projects at close:', {
projectsLength: projects?.length,
});
setIsNoteModalOpen(false);
}}
onSave={handleSaveNote}
onDelete={async (noteId) => {
try {
await apiDeleteNote(noteId);
setNotes((prev) => prev.filter((note) => note.id !== noteId));
setNotes((prev) =>
prev.filter((note) => note.id !== noteId)
);
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
@ -277,20 +346,35 @@ const Notes: React.FC = () => {
}
}}
note={selectedNote}
projects={projects?.length > 0 ? projects : [
{ id: 1, name: 'Test Project 1', active: true, priority: 'medium' },
{ id: 2, name: 'tududi', active: true, priority: 'high' }
] as any}
projects={
projects?.length > 0
? projects
: ([
{
id: 1,
name: 'Test Project 1',
active: true,
priority: 'medium',
},
{
id: 2,
name: 'tududi',
active: true,
priority: 'high',
},
] as any)
}
onCreateProject={handleCreateProject}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title={t('modals.deleteNote.title')}
message={t('modals.deleteNote.message', { noteTitle: noteToDelete.title })}
message={t('modals.deleteNote.message', {
noteTitle: noteToDelete.title,
})}
onConfirm={handleDeleteNote}
onCancel={() => setIsConfirmDialogOpen(false)}
/>

View file

@ -7,18 +7,28 @@ import {
ClockIcon,
FolderIcon,
ChevronDownIcon,
ChevronRightIcon
ChevronRightIcon,
} from '@heroicons/react/24/outline';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskModal from '../Task/TaskModal';
import { fetchTaskById, updateTask, deleteTask } from '../../utils/tasksService';
import {
fetchTaskById,
updateTask,
deleteTask,
} from '../../utils/tasksService';
import { fetchProjects, createProject } from '../../utils/projectsService';
import { useToast } from '../Shared/ToastContext';
import { getVagueTasks } from '../../utils/taskIntelligenceService';
interface ProductivityInsight {
type: 'stalled_projects' | 'completed_no_next' | 'tasks_are_projects' | 'vague_tasks' | 'overdue_tasks' | 'stuck_projects';
type:
| 'stalled_projects'
| 'completed_no_next'
| 'tasks_are_projects'
| 'vague_tasks'
| 'overdue_tasks'
| 'stuck_projects';
title: string;
description: string;
items: (Task | Project)[];
@ -31,14 +41,19 @@ interface ProductivityAssistantProps {
projects: Project[];
}
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, projects }) => {
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
tasks,
projects,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showSuccessToast, showErrorToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
const [insights, setInsights] = useState<ProductivityInsight[]>([]);
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(new Set());
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(
new Set()
);
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
@ -46,7 +61,18 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const [allProjects, setAllProjects] = useState<Project[]>(projects);
const [loading, setLoading] = useState(false);
const PROJECT_VERBS = ['plan', 'organize', 'set up', 'setup', 'fix', 'review', 'implement', 'create', 'build', 'develop'];
const PROJECT_VERBS = [
'plan',
'organize',
'set up',
'setup',
'fix',
'review',
'implement',
'create',
'build',
'develop',
];
const OVERDUE_THRESHOLD_DAYS = 30;
useEffect(() => {
@ -54,30 +80,48 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const newInsights: ProductivityInsight[] = [];
// Filter to only include non-completed tasks
const activeTasks = tasks.filter(task => task.status !== 'done' && task.status !== 'archived');
const activeTasks = tasks.filter(
(task) => task.status !== 'done' && task.status !== 'archived'
);
// 1. Stalled Projects (no tasks/actions)
const stalledProjects = projects.filter(project =>
project.active && !activeTasks.some(task => task.project_id === project.id)
const stalledProjects = projects.filter(
(project) =>
project.active &&
!activeTasks.some((task) => task.project_id === project.id)
);
if (stalledProjects.length > 0) {
newInsights.push({
type: 'stalled_projects',
title: t('productivity.stalledProjects', 'Stalled Projects'),
description: t('productivity.stalledProjectsDesc', 'These projects have no tasks or actions'),
title: t(
'productivity.stalledProjects',
'Stalled Projects'
),
description: t(
'productivity.stalledProjectsDesc',
'These projects have no tasks or actions'
),
items: stalledProjects,
icon: FolderIcon,
color: 'text-red-500'
color: 'text-red-500',
});
}
// 2. Projects with completed tasks but no next action
const projectsNeedingNextAction = projects.filter(project => {
const projectTasks = tasks.filter(task => task.project_id === project.id);
const hasCompletedTasks = projectTasks.some(task => task.status === 'done' || task.status === 'archived');
const hasNextAction = activeTasks.some(task =>
task.project_id === project.id && (task.status === 'not_started' || task.status === 'in_progress')
const projectsNeedingNextAction = projects.filter((project) => {
const projectTasks = tasks.filter(
(task) => task.project_id === project.id
);
const hasCompletedTasks = projectTasks.some(
(task) =>
task.status === 'done' || task.status === 'archived'
);
const hasNextAction = activeTasks.some(
(task) =>
task.project_id === project.id &&
(task.status === 'not_started' ||
task.status === 'in_progress')
);
return project.active && hasCompletedTasks && !hasNextAction;
});
@ -85,29 +129,43 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
if (projectsNeedingNextAction.length > 0) {
newInsights.push({
type: 'completed_no_next',
title: t('productivity.needsNextAction', 'Projects Need Next Action'),
description: t('productivity.needsNextActionDesc', 'These projects have completed tasks but no next action'),
title: t(
'productivity.needsNextAction',
'Projects Need Next Action'
),
description: t(
'productivity.needsNextActionDesc',
'These projects have completed tasks but no next action'
),
items: projectsNeedingNextAction,
icon: ExclamationTriangleIcon,
color: 'text-yellow-500'
color: 'text-yellow-500',
});
}
// 3. Tasks that are actually projects
const tasksAreProjects = activeTasks.filter(task => {
const tasksAreProjects = activeTasks.filter((task) => {
const taskName = task.name.toLowerCase();
return PROJECT_VERBS.some(verb => taskName.includes(verb)) &&
taskName.length > 30; // Longer tasks are more likely to be projects
return (
PROJECT_VERBS.some((verb) => taskName.includes(verb)) &&
taskName.length > 30
); // Longer tasks are more likely to be projects
});
if (tasksAreProjects.length > 0) {
newInsights.push({
type: 'tasks_are_projects',
title: t('productivity.tasksAreProjects', 'Tasks That Look Like Projects'),
description: t('productivity.tasksAreProjectsDesc', 'These tasks might need to be broken down'),
title: t(
'productivity.tasksAreProjects',
'Tasks That Look Like Projects'
),
description: t(
'productivity.tasksAreProjectsDesc',
'These tasks might need to be broken down'
),
items: tasksAreProjects,
icon: AcademicCapIcon,
color: 'text-blue-500'
color: 'text-blue-500',
});
}
@ -117,21 +175,31 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
if (vagueTasks.length > 0) {
newInsights.push({
type: 'vague_tasks',
title: t('productivity.vagueTasks', 'Tasks Without Clear Action'),
description: t('productivity.vagueTasksDesc', 'These tasks need clearer action verbs'),
title: t(
'productivity.vagueTasks',
'Tasks Without Clear Action'
),
description: t(
'productivity.vagueTasksDesc',
'These tasks need clearer action verbs'
),
items: vagueTasks,
icon: ExclamationTriangleIcon,
color: 'text-orange-500'
color: 'text-orange-500',
});
}
// 5. Overdue or stale tasks
const now = new Date();
const thresholdDate = new Date(now.getTime() - (OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000));
const thresholdDate = new Date(
now.getTime() - OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
);
const staleTasks = activeTasks.filter(task => {
const staleTasks = activeTasks.filter((task) => {
// Only use created_at since updated_at doesn't exist in the interface
const taskDate = task.created_at ? new Date(task.created_at) : null;
const taskDate = task.created_at
? new Date(task.created_at)
: null;
return taskDate && taskDate < thresholdDate;
});
@ -140,28 +208,39 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
newInsights.push({
type: 'overdue_tasks',
title: t('productivity.staleTasks', 'Stale Tasks'),
description: t('productivity.staleTasksDesc', 'Tasks not updated in {{days}} days', { days: OVERDUE_THRESHOLD_DAYS }),
description: t(
'productivity.staleTasksDesc',
'Tasks not updated in {{days}} days',
{ days: OVERDUE_THRESHOLD_DAYS }
),
items: staleTasks,
icon: ClockIcon,
color: 'text-gray-500'
color: 'text-gray-500',
});
}
// 6. Stuck projects (not updated in a month)
const stuckProjects = projects.filter(project => {
const stuckProjects = projects.filter((project) => {
if (!project.active) return false;
// Projects don't have date fields in the interface, so we'll check if they have recent tasks
const projectTasks = activeTasks.filter(task => task.project_id === project.id);
const projectTasks = activeTasks.filter(
(task) => task.project_id === project.id
);
if (projectTasks.length === 0) return false; // Empty projects are handled by "stalled projects"
// Find the most recent task date for this project
const mostRecentTaskDate = projectTasks.reduce((latest, task) => {
const taskDate = task.created_at ? new Date(task.created_at) : null;
const mostRecentTaskDate = projectTasks.reduce(
(latest, task) => {
const taskDate = task.created_at
? new Date(task.created_at)
: null;
if (!taskDate) return latest;
return !latest || taskDate > latest ? taskDate : latest;
}, null as Date | null);
},
null as Date | null
);
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
});
@ -170,10 +249,13 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
newInsights.push({
type: 'stuck_projects',
title: t('productivity.stuckProjects', 'Stuck Projects'),
description: t('productivity.stuckProjectsDesc', 'Projects not updated recently'),
description: t(
'productivity.stuckProjectsDesc',
'Projects not updated recently'
),
items: stuckProjects,
icon: FolderIcon,
color: 'text-purple-500'
color: 'text-purple-500',
});
}
@ -183,7 +265,10 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
generateInsights();
}, [tasks, projects, t]);
const totalIssues = insights.reduce((sum, insight) => sum + insight.items.length, 0);
const totalIssues = insights.reduce(
(sum, insight) => sum + insight.items.length,
0
);
const toggleInsightExpansion = (index: number) => {
const newExpanded = new Set(expandedInsights);
@ -207,7 +292,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
setIsTaskModalOpen(true);
} catch (error) {
console.error('Failed to fetch task:', error);
showErrorToast(t('errors.failedToLoadTask', 'Failed to load task'));
showErrorToast(
t('errors.failedToLoadTask', 'Failed to load task')
);
} finally {
setLoading(false);
}
@ -223,7 +310,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
await updateTask(updatedTask.id, updatedTask);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(t('task.updateSuccess', 'Task updated successfully'));
showSuccessToast(
t('task.updateSuccess', 'Task updated successfully')
);
// Optionally refresh the parent component data
}
} catch (error) {
@ -238,7 +327,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
await deleteTask(selectedTask.id);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(t('task.deleteSuccess', 'Task deleted successfully'));
showSuccessToast(
t('task.deleteSuccess', 'Task deleted successfully')
);
// Optionally refresh the parent component data
}
} catch (error) {
@ -250,7 +341,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
setAllProjects(prev => [...prev, project]);
setAllProjects((prev) => [...prev, project]);
return project;
} catch (error) {
console.error('Failed to create project:', error);
@ -289,10 +380,17 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" />
<div className="flex-1 text-left">
<p className="text-gray-700 dark:text-gray-300 font-medium">
{t('productivity.issuesFound', 'Found {{count}} productivity issue(s) that need attention', { count: totalIssues })}
{t(
'productivity.issuesFound',
'Found {{count}} productivity issue(s) that need attention',
{ count: totalIssues }
)}
</p>
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
{t('productivity.reviewItems', 'Click to review and improve your workflow')}
{t(
'productivity.reviewItems',
'Click to review and improve your workflow'
)}
</p>
</div>
{isExpanded ? (
@ -306,22 +404,38 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="space-y-4">
{insights.map((insight, index) => (
<div key={index} className="border-l-4 border-gray-200 dark:border-gray-600 pl-4">
<div
key={index}
className="border-l-4 border-gray-200 dark:border-gray-600 pl-4"
>
<div className="flex items-start space-x-3">
<insight.icon className={`h-5 w-5 mt-0.5 ${insight.color}`} />
<insight.icon
className={`h-5 w-5 mt-0.5 ${insight.color}`}
/>
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-gray-100">
{insight.title} ({insight.items.length})
{insight.title} (
{insight.items.length})
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{insight.description}
</p>
<div className="space-y-1">
{(expandedInsights.has(index) ? insight.items : insight.items.slice(0, 3)).map((item, itemIndex) => {
{(expandedInsights.has(index)
? insight.items
: insight.items.slice(0, 3)
).map((item, itemIndex) => {
return (
<div key={itemIndex} className="text-sm">
<div
key={itemIndex}
className="text-sm"
>
<button
onClick={() => handleItemClick(item)}
onClick={() =>
handleItemClick(
item
)
}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline text-left"
disabled={loading}
>
@ -332,13 +446,16 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
})}
{insight.items.length > 3 && (
<button
onClick={() => toggleInsightExpansion(index)}
onClick={() =>
toggleInsightExpansion(
index
)
}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline cursor-pointer"
>
{expandedInsights.has(index)
? '... show less'
: `... and ${insight.items.length - 3} more items`
}
: `... and ${insight.items.length - 3} more items`}
</button>
)}
</div>
@ -350,7 +467,10 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('productivity.suggestion', 'Click on any item above to open it and make improvements.')}
{t(
'productivity.suggestion',
'Click on any item above to open it and make improvements.'
)}
</p>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "../Shared/ToastContext";
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../Shared/ToastContext';
interface AutoSuggestNextActionBoxProps {
onAddAction: (actionDescription: string) => void;
@ -11,9 +11,9 @@ interface AutoSuggestNextActionBoxProps {
const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
onAddAction,
onDismiss,
projectName,
projectName, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => {
const [actionDescription, setActionDescription] = useState("");
const [actionDescription, setActionDescription] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@ -32,12 +32,12 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
if (actionDescription.trim()) {
onAddAction(actionDescription.trim());
showSuccessToast(t('success.nextActionAdded'));
setActionDescription("");
setActionDescription('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
onDismiss();
}
};
@ -63,7 +63,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
</div>
<div>
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-1">
{t('profile.nextActionPrompt', 'What\'s the very next physical action for this project?')}
{t(
'profile.nextActionPrompt',
"What's the very next physical action for this project?"
)}
</h3>
</div>
</div>
@ -72,8 +75,18 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 transition-colors"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@ -88,7 +101,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder={t('profile.nextActionPlaceholder', 'e.g., Call John to schedule meeting, Research competitors online, Create project folder...')}
placeholder={t(
'profile.nextActionPlaceholder',
'e.g., Call John to schedule meeting, Research competitors online, Create project folder...'
)}
className={`w-full px-4 py-3 border rounded-lg shadow-sm transition-all duration-200 focus:outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 ${
isFocused
? 'border-blue-400 ring-2 ring-blue-100 dark:ring-blue-900/50'
@ -124,11 +140,24 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
<div className="mt-4 p-3 bg-blue-100/50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700">
<p className="text-xs text-blue-700 dark:text-blue-300 flex items-center">
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="w-4 h-4 mr-2 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{t('profile.nextActionHint', 'Think of the smallest, most concrete step you can take right now to move this project forward.')}
{t(
'profile.nextActionHint',
'Think of the smallest, most concrete step you can take right now to move this project forward.'
)}
</span>
</p>
</div>

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useToast } from "../Shared/ToastContext";
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useToast } from '../Shared/ToastContext';
import {
PencilSquareIcon,
TrashIcon,
@ -9,23 +9,34 @@ import {
Squares2X2Icon,
BookOpenIcon,
TagIcon,
ListBulletIcon
} from "@heroicons/react/24/outline";
import TaskList from "../Task/TaskList";
import ProjectModal from "../Project/ProjectModal";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useStore } from "../../store/useStore";
import NewTask from "../Task/NewTask";
import { Project } from "../../entities/Project";
import { PriorityType, Task } from "../../entities/Task";
import { Note } from "../../entities/Note";
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
import { createTask, deleteTask, toggleTaskToday } from "../../utils/tasksService";
import { fetchAreas } from "../../utils/areasService";
import { isAuthError } from "../../utils/authUtils";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
import { getAutoSuggestNextActionsEnabled } from "../../utils/profileService";
import AutoSuggestNextActionBox from "./AutoSuggestNextActionBox";
ListBulletIcon,
} from '@heroicons/react/24/outline';
import TaskList from '../Task/TaskList';
import ProjectModal from '../Project/ProjectModal';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useStore } from '../../store/useStore';
import NewTask from '../Task/NewTask';
import { Project } from '../../entities/Project';
import { PriorityType, Task } from '../../entities/Task';
import { Note } from '../../entities/Note';
import {
fetchProjectById,
updateProject,
deleteProject,
} from '../../utils/projectsService';
import {
createTask,
deleteTask,
toggleTaskToday,
} from '../../utils/tasksService';
import { fetchAreas } from '../../utils/areasService';
import { isAuthError } from '../../utils/authUtils';
import {
CalendarDaysIcon,
InformationCircleIcon,
} from '@heroicons/react/24/solid';
import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService';
import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
type PriorityStyles = Record<PriorityType, string> & { default: string };
@ -48,7 +59,7 @@ const ProjectDetails: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
@ -59,7 +70,7 @@ const ProjectDetails: React.FC = () => {
useEffect(() => {
const loadProjectData = async () => {
if (!id) {
console.error("Project ID is missing.");
console.error('Project ID is missing.');
return;
}
@ -69,13 +80,15 @@ const ProjectDetails: React.FC = () => {
const projectData = await fetchProjectById(id);
setProject(projectData);
// Handle both 'tasks' and 'Tasks' property names
const projectTasks = projectData.tasks || projectData.Tasks || [];
const projectTasks =
projectData.tasks || projectData.Tasks || [];
setTasks(projectTasks);
// Handle project notes
const projectNotes = projectData.notes || projectData.Notes || [];
const projectNotes =
projectData.notes || projectData.Notes || [];
setNotes(projectNotes);
} catch (error) {
console.error("Error fetching project data:", error);
console.error('Error fetching project data:', error);
} finally {
setLoading(false);
}
@ -88,7 +101,8 @@ const ProjectDetails: React.FC = () => {
useEffect(() => {
const checkAutoSuggest = async () => {
if (project && tasks.length === 0 && !loading) {
const autoSuggestEnabled = await getAutoSuggestNextActionsEnabled();
const autoSuggestEnabled =
await getAutoSuggestNextActionsEnabled();
if (autoSuggestEnabled) {
setShowAutoSuggestForm(true);
}
@ -100,14 +114,14 @@ const ProjectDetails: React.FC = () => {
const handleTaskCreate = async (taskName: string) => {
if (!project) {
console.error("Cannot create task: Project is missing");
throw new Error("Cannot create task: Project is missing");
console.error('Cannot create task: Project is missing');
throw new Error('Cannot create task: Project is missing');
}
try {
const newTask = await createTask({
name: taskName,
status: "not_started",
status: 'not_started',
project_id: project.id,
});
setTasks((prevTasks) => [...prevTasks, newTask]);
@ -115,12 +129,19 @@ const ProjectDetails: React.FC = () => {
// Show success toast with task link
const taskLink = (
<span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
} catch (err: any) {
console.error("Error creating task:", err);
console.error('Error creating task:', err);
// Check if it's an authentication error
if (isAuthError(err)) {
return;
@ -131,22 +152,22 @@ const ProjectDetails: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => {
if (!updatedTask.id) {
console.error("Cannot update task: Task ID is missing");
console.error('Cannot update task: Task ID is missing');
return;
}
try {
// Use direct fetch call like Tasks.tsx to ensure proper tag saving
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updatedTask),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Failed to update task:", errorData.error);
throw new Error("Failed to update task");
console.error('Failed to update task:', errorData.error);
throw new Error('Failed to update task');
}
const savedTask = await response.json();
@ -156,20 +177,22 @@ const ProjectDetails: React.FC = () => {
)
);
} catch (err) {
console.error("Error updating task:", err);
console.error('Error updating task:', err);
}
};
const handleTaskDelete = async (taskId: number | undefined) => {
if (!taskId) {
console.error("Cannot delete task: Task ID is missing");
console.error('Cannot delete task: Task ID is missing');
return;
}
try {
await deleteTask(taskId);
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} catch (err) {
console.error("Error deleting task:", err);
console.error('Error deleting task:', err);
}
};
@ -177,13 +200,19 @@ const ProjectDetails: React.FC = () => {
try {
const updatedTask = await toggleTaskToday(taskId);
// Update the task in the local state immediately to avoid UI flashing
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId
? {
...task,
today: updatedTask.today,
today_move_count: updatedTask.today_move_count,
}
: task
)
);
} catch (error) {
console.error("Error toggling task today status:", error);
console.error('Error toggling task today status:', error);
// Optionally refetch data on error to ensure consistency
if (id) {
try {
@ -191,7 +220,10 @@ const ProjectDetails: React.FC = () => {
setProject(updatedProject);
setTasks(updatedProject.tasks || []);
} catch (refetchError) {
console.error("Error refetching project data:", refetchError);
console.error(
'Error refetching project data:',
refetchError
);
}
}
}
@ -203,60 +235,72 @@ const ProjectDetails: React.FC = () => {
const handleSaveProject = async (updatedProject: Project) => {
if (!updatedProject.id) {
console.error("Cannot save project: Project ID is missing");
console.error('Cannot save project: Project ID is missing');
return;
}
try {
const savedProject = await updateProject(updatedProject.id, updatedProject);
const savedProject = await updateProject(
updatedProject.id,
updatedProject
);
setProject(savedProject);
setIsModalOpen(false);
} catch (err) {
console.error("Error saving project:", err);
console.error('Error saving project:', err);
}
};
const handleCreateNextAction = async (projectId: number, actionDescription: string) => {
const handleCreateNextAction = async (
projectId: number,
actionDescription: string
) => {
try {
const newTask = await createTask({
name: actionDescription,
status: "not_started",
status: 'not_started',
project_id: projectId,
priority: "medium"
priority: 'medium',
});
// Update the tasks list to include the new task
setTasks(prevTasks => [...prevTasks, newTask]);
setTasks((prevTasks) => [...prevTasks, newTask]);
setShowAutoSuggestForm(false);
// Show success toast with task link
const taskLink = (
<span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error("Error creating next action:", error);
console.error('Error creating next action:', error);
}
};
const handleSkipNextAction = () => {
setShowAutoSuggestForm(false);
};
const handleDeleteProject = async () => {
if (!project?.id) {
console.error("Cannot delete project: Project ID is missing");
console.error('Cannot delete project: Project ID is missing');
return;
}
try {
await deleteProject(project.id);
navigate("/projects");
navigate('/projects');
} catch (err) {
console.error("Error deleting project:", err);
console.error('Error deleting project:', err);
}
};
@ -286,14 +330,21 @@ const ProjectDetails: React.FC = () => {
);
}
const activeTasks = tasks?.filter((task) => {
return typeof task.status === 'number' ? task.status !== 2 : task.status !== 'done';
const activeTasks =
tasks?.filter((task) => {
return typeof task.status === 'number'
? task.status !== 2
: task.status !== 'done';
}) || []; //TODO: Also add archived
const completedTasks = tasks?.filter((task) => {
return typeof task.status === 'number' ? task.status === 2 : task.status === 'done';
return typeof task.status === 'number'
? task.status === 2
: task.status === 'done';
});
const displayTasks = showCompleted ? [...activeTasks, ...completedTasks] : activeTasks;
const displayTasks = showCompleted
? [...activeTasks, ...completedTasks]
: activeTasks;
const formatProjectDueDate = (dateString: string) => {
const date = new Date(dateString);
@ -303,7 +354,7 @@ const ProjectDetails: React.FC = () => {
const formatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
};
return date.toLocaleDateString(currentLang, formatOptions);
@ -327,12 +378,13 @@ const ProjectDetails: React.FC = () => {
</h1>
</div>
{/* Priority Indicator on Image */}
{project.priority !== undefined && project.priority !== null && (
{project.priority !== undefined &&
project.priority !== null && (
<div className="absolute top-3 left-3">
<div
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${
getPriorityStyle(project.priority)
}`}
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${getPriorityStyle(
project.priority
)}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
@ -357,14 +409,19 @@ const ProjectDetails: React.FC = () => {
)}
{/* Project Metadata Box */}
{(project.description || project.area || project.due_date_at || (project.tags && project.tags.length > 0)) && (
{(project.description ||
project.area ||
project.due_date_at ||
(project.tags && project.tags.length > 0)) && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="grid gap-3">
{project.description && (
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Description:</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Description:
</span>
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mt-1">
{project.description}
</p>
@ -375,7 +432,9 @@ const ProjectDetails: React.FC = () => {
{project.area && (
<div className="flex items-center">
<Squares2X2Icon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Area:</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Area:
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{project.area.name}
</span>
@ -385,9 +444,13 @@ const ProjectDetails: React.FC = () => {
{project.due_date_at && (
<div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Due Date:</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Due Date:
</span>
<span className="text-sm text-gray-900 dark:text-gray-100">
{formatProjectDueDate(project.due_date_at)}
{formatProjectDueDate(
project.due_date_at
)}
</span>
</div>
)}
@ -395,17 +458,30 @@ const ProjectDetails: React.FC = () => {
{project.tags && project.tags.length > 0 && (
<div className="flex items-start">
<div className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5">
<svg fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
<svg
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Tags:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{project.tags.map((tag, index) => (
<button
key={index}
onClick={() => navigate(`/tag/${encodeURIComponent(tag.name)}`)}
onClick={() =>
navigate(
`/tag/${encodeURIComponent(tag.name)}`
)
}
className="inline-block px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
>
{tag.name}
@ -428,11 +504,12 @@ const ProjectDetails: React.FC = () => {
{project.name}
</h2>
{/* Show priority indicator only when no image */}
{project.priority !== undefined && project.priority !== null && (
{project.priority !== undefined &&
project.priority !== null && (
<div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
getPriorityStyle(project.priority)
}`}
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${getPriorityStyle(
project.priority
)}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
@ -459,24 +536,38 @@ const ProjectDetails: React.FC = () => {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<ListBulletIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">{t('sidebar.tasks', 'Tasks')}</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t('sidebar.tasks', 'Tasks')}
</h3>
</div>
{completedTasks.length > 0 && (
<label className="flex items-center space-x-2 cursor-pointer">
<span className="text-sm text-gray-600 dark:text-gray-400">Show completed</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
Show completed
</span>
<div className="relative flex items-center">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)}
onChange={(e) =>
setShowCompleted(e.target.checked)
}
className="sr-only"
/>
<div className={`w-10 h-5 rounded-full transition-colors ${
showCompleted ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}>
<div className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
showCompleted ? 'translate-x-5' : 'translate-x-0.5'
} translate-y-0.5`}></div>
<div
className={`w-10 h-5 rounded-full transition-colors ${
showCompleted
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
showCompleted
? 'translate-x-5'
: 'translate-x-0.5'
} translate-y-0.5`}
></div>
</div>
</div>
</label>
@ -502,14 +593,19 @@ const ProjectDetails: React.FC = () => {
<AutoSuggestNextActionBox
onAddAction={(actionDescription) => {
if (project?.id) {
handleCreateNextAction(project.id, actionDescription);
handleCreateNextAction(
project.id,
actionDescription
);
}
}}
onDismiss={handleSkipNextAction}
projectName={project?.name || ""}
projectName={project?.name || ''}
/>
) : (
<p className="text-gray-500 dark:text-gray-400">No tasks.</p>
<p className="text-gray-500 dark:text-gray-400">
No tasks.
</p>
)}
</div>
@ -533,20 +629,34 @@ const ProjectDetails: React.FC = () => {
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{note.title || t('notes.untitled', 'Untitled Note')}
{note.title ||
t(
'notes.untitled',
'Untitled Note'
)}
</Link>
{note.content && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{note.content.length > 150
? note.content.substring(0, 150) + '...'
: note.content
}
? note.content.substring(
0,
150
) + '...'
: note.content}
</p>
)}
{note.tags && note.tags.length > 0 && (
{note.tags &&
note.tags.length > 0 && (
<div className="flex items-center mt-2 text-xs text-gray-500 dark:text-gray-400">
<TagIcon className="h-3 w-3 mr-1" />
<span>{note.tags.map(tag => tag.name).join(', ')}</span>
<span>
{note.tags
.map(
(tag) =>
tag.name
)
.join(', ')}
</span>
</div>
)}
</div>
@ -555,7 +665,9 @@ const ProjectDetails: React.FC = () => {
))}
</div>
) : (
<p className="text-gray-500 dark:text-gray-400">{t('notes.noNotes', 'No notes for this project.')}</p>
<p className="text-gray-500 dark:text-gray-400">
{t('notes.noNotes', 'No notes for this project.')}
</p>
)}
</div>
@ -575,7 +687,6 @@ const ProjectDetails: React.FC = () => {
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
@ -583,7 +694,8 @@ const ProjectDetails: React.FC = () => {
const priorityLabel = (priority: PriorityType | number) => {
// Handle both string and numeric priorities
const normalizedPriority = typeof priority === 'number'
const normalizedPriority =
typeof priority === 'number'
? (['low', 'medium', 'high'][priority] as PriorityType)
: priority;
@ -601,7 +713,8 @@ const priorityLabel = (priority: PriorityType | number) => {
const getPriorityStyle = (priority: PriorityType | number) => {
// Handle both string and numeric priorities
const normalizedPriority = typeof priority === 'number'
const normalizedPriority =
typeof priority === 'number'
? (['low', 'medium', 'high'][priority] as PriorityType)
: priority;

View file

@ -1,12 +1,12 @@
import React from "react";
import { Link } from "react-router-dom";
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import { Project } from "../../entities/Project";
import { useTranslation } from "react-i18next";
import React from 'react';
import { Link } from 'react-router-dom';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Project } from '../../entities/Project';
import { useTranslation } from 'react-i18next';
interface ProjectItemProps {
project: Project;
viewMode: "cards" | "list";
viewMode: 'cards' | 'list';
color: string;
getCompletionPercentage: () => number;
activeDropdown: number | null;
@ -19,12 +19,12 @@ interface ProjectItemProps {
const getProjectInitials = (name: string) => {
const words = name
.trim()
.split(" ")
.split(' ')
.filter((word) => word.length > 0);
if (words.length === 1) {
return name.toUpperCase();
}
return words.map((word) => word[0].toUpperCase()).join("");
return words.map((word) => word[0].toUpperCase()).join('');
};
const ProjectItem: React.FC<ProjectItemProps> = ({
@ -42,19 +42,19 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
return (
<div
className={`${
viewMode === "cards"
? "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col"
: "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4"
viewMode === 'cards'
? 'bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col'
: 'bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4'
}`}
style={{
minHeight: viewMode === "cards" ? "250px" : "auto",
maxHeight: viewMode === "cards" ? "250px" : "auto",
minHeight: viewMode === 'cards' ? '250px' : 'auto',
maxHeight: viewMode === 'cards' ? '250px' : 'auto',
}}
>
{viewMode === "cards" && (
{viewMode === 'cards' && (
<div
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg relative"
style={{ height: "140px" }}
style={{ height: '140px' }}
>
{project.image_url ? (
<img
@ -65,7 +65,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
) : (
<span
className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20"
aria-label={t("projectItem.projectInitials")}
aria-label={t('projectItem.projectInitials')}
>
{getProjectInitials(project.name)}
</span>
@ -76,7 +76,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
</div>
)}
{viewMode === "list" && project.image_url && (
{viewMode === 'list' && project.image_url && (
<div className="w-16 h-16 mr-4 flex-shrink-0">
<img
src={project.image_url}
@ -88,19 +88,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
<div
className={`flex justify-between items-start ${
viewMode === "cards" ? "p-4 flex-1" : "flex-1"
viewMode === 'cards' ? 'p-4 flex-1' : 'flex-1'
}`}
>
<div className="flex items-center">
{viewMode === "list" && !project.image_url && (
<div className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}></div>
{viewMode === 'list' && !project.image_url && (
<div
className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}
></div>
)}
<Link
to={`/project/${project.id}`}
className={`${
viewMode === "cards"
? "text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2"
: "text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
viewMode === 'cards'
? 'text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2'
: 'text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline'
}`}
>
{project.name}
@ -111,10 +113,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none"
onClick={() =>
setActiveDropdown(
activeDropdown === project.id ? null : project.id ?? null
activeDropdown === project.id
? null
: (project.id ?? null)
)
}
aria-label={t("projectItem.toggleDropdownMenu")}
aria-label={t('projectItem.toggleDropdownMenu')}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
@ -125,7 +129,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
onClick={() => handleEditProject(project)}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
>
{t("projectItem.edit")}
{t('projectItem.edit')}
</button>
<button
onClick={() => {
@ -135,19 +139,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
}}
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
>
{t("projectItem.delete")}
{t('projectItem.delete')}
</button>
</div>
)}
</div>
</div>
{viewMode === "cards" && (
{viewMode === 'cards' && (
<div className="absolute bottom-4 left-0 right-0 px-4">
<div className="flex items-center space-x-2">
<div
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
title={t("projectItem.completionPercentage", { percentage: getCompletionPercentage() })}
title={t('projectItem.completionPercentage', {
percentage: getCompletionPercentage(),
})}
>
<div
className="bg-blue-500 h-2 rounded-full"

View file

@ -1,15 +1,23 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Area } from "../../entities/Area";
import { Project } from "../../entities/Project";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useToast } from "../Shared/ToastContext";
import TagInput from "../Tag/TagInput";
import PriorityDropdown from "../Shared/PriorityDropdown";
import { PriorityType } from "../../entities/Task";
import Switch from "../Shared/Switch";
import { useStore } from "../../store/useStore";
import { useTranslation } from "react-i18next";
import { TagIcon, FolderIcon, Cog6ToothIcon, TrashIcon, CameraIcon, CalendarIcon, ExclamationTriangleIcon, PowerIcon } from '@heroicons/react/24/outline';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Area } from '../../entities/Area';
import { Project } from '../../entities/Project';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from '../Shared/ToastContext';
import TagInput from '../Tag/TagInput';
import PriorityDropdown from '../Shared/PriorityDropdown';
import { PriorityType } from '../../entities/Task';
import Switch from '../Shared/Switch';
import { useStore } from '../../store/useStore';
import { useTranslation } from 'react-i18next';
import {
TagIcon,
FolderIcon,
TrashIcon,
CameraIcon,
CalendarIcon,
ExclamationTriangleIcon,
PowerIcon,
} from '@heroicons/react/24/outline';
interface ProjectModalProps {
isOpen: boolean;
@ -30,14 +38,14 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}) => {
const [formData, setFormData] = useState<Project>(
project || {
name: "",
description: "",
name: '',
description: '',
area_id: null,
active: true,
tags: [],
priority: "low",
due_date_at: "",
image_url: "",
priority: 'low',
due_date_at: '',
image_url: '',
}
);
@ -45,7 +53,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
project?.tags?.map((tag) => tag.name) || []
);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>(project?.image_url || "");
const [imagePreview, setImagePreview] = useState<string>(
project?.image_url || ''
);
const [isUploading, setIsUploading] = useState(false);
const { tagsStore } = useStore();
@ -67,7 +77,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
active: false,
});
const { showSuccessToast, showErrorToast } = useToast();
const { showSuccessToast } = useToast();
const { t } = useTranslation();
useEffect(() => {
@ -75,24 +85,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
setFormData({
...project,
tags: project.tags || [],
due_date_at: project.due_date_at || "",
image_url: project.image_url || "",
due_date_at: project.due_date_at || '',
image_url: project.image_url || '',
});
setTags(project.tags?.map((tag) => tag.name) || []);
setImagePreview(project.image_url || "");
setImagePreview(project.image_url || '');
} else {
setFormData({
name: "",
description: "",
name: '',
description: '',
area_id: null,
active: true,
tags: [],
priority: "low",
due_date_at: "",
image_url: "",
priority: 'low',
due_date_at: '',
image_url: '',
});
setTags([]);
setImagePreview("");
setImagePreview('');
}
setImageFile(null);
setError(null);
@ -109,7 +119,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
// Check if click is on priority dropdown (which is portaled to document.body)
const clickedElement = target as Element;
if (clickedElement && clickedElement.closest && clickedElement.closest('.fixed.z-50.bg-white, .fixed.z-50.bg-gray-700')) {
if (
clickedElement &&
clickedElement.closest &&
clickedElement.closest(
'.fixed.z-50.bg-white, .fixed.z-50.bg-gray-700'
)
) {
return;
}
@ -117,24 +133,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === 'Escape') {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
@ -151,7 +167,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
setError(null);
}
if (type === "checkbox") {
if (type === 'checkbox') {
if (target instanceof HTMLInputElement) {
const checked = target.checked;
setFormData((prev) => ({
@ -219,20 +235,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const handleRemoveImage = () => {
setImageFile(null);
setImagePreview("");
setImagePreview('');
setFormData((prev) => ({
...prev,
image_url: "",
image_url: '',
}));
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = '';
}
};
const handleSubmit = async () => {
// Validate required fields
if (!formData.name.trim()) {
setError(t('errors.projectNameRequired', 'Project name is required'));
setError(
t('errors.projectNameRequired', 'Project name is required')
);
return;
}
@ -250,7 +268,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const projectData = {
...formData,
image_url: imageUrl,
tags: tags.map((name) => ({ name }))
tags: tags.map((name) => ({ name })),
};
// Save the project
@ -258,8 +276,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
showSuccessToast(
project
? "Project updated successfully!"
: "Project created successfully!"
? 'Project updated successfully!'
: 'Project created successfully!'
);
handleClose();
@ -297,21 +315,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}));
};
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
setExpandedSections(prev => {
const toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = {
...prev,
[section]: !prev[section]
[section]: !prev[section],
};
// Auto-scroll to show the expanded section
if (newExpanded[section]) {
setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth'
behavior: 'smooth',
});
}
}, 100); // Small delay to ensure DOM is updated
@ -319,8 +340,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
return newExpanded;
});
}, []);
},
[]
);
if (!isOpen) return null;
@ -328,20 +350,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100"
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[500px] sm:max-h-[80vh]">
{/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}>
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Project Title Section - Always Visible */}
@ -354,7 +379,10 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onChange={handleChange}
required
className={`block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2`}
placeholder={t('project.name', 'Enter project name')}
placeholder={t(
'project.name',
'Enter project name'
)}
/>
{error && (
<div className="mt-2 text-red-500 text-sm font-medium">
@ -368,11 +396,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<textarea
id="projectDescription"
name="description"
value={formData.description || ""}
value={
formData.description ||
''
}
onChange={handleChange}
className="block w-full h-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out resize-none"
placeholder={t('forms.areaDescriptionPlaceholder', 'Enter project description (optional)')}
style={{ minHeight: '200px' }}
placeholder={t(
'forms.areaDescriptionPlaceholder',
'Enter project description (optional)'
)}
style={{
minHeight: '200px',
}}
/>
</div>
@ -381,18 +417,28 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.active && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('projects.active', 'Status')}
{t(
'projects.active',
'Status'
)}
</h3>
<div className="flex items-center">
<Switch
isChecked={formData.active}
onToggle={handleToggleActive}
isChecked={
formData.active
}
onToggle={
handleToggleActive
}
/>
<label
htmlFor="active"
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
>
{t('projects.active', 'Active')}
{t(
'projects.active',
'Active'
)}
</label>
</div>
</div>
@ -401,12 +447,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.tags && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.tags', 'Tags')}
{t(
'forms.tags',
'Tags'
)}
</h3>
<TagInput
onTagsChange={handleTagsChange}
onTagsChange={
handleTagsChange
}
initialTags={tags}
availableTags={availableTags}
availableTags={
availableTags
}
/>
</div>
)}
@ -414,18 +467,32 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.area && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('common.area', 'Area')}
{t(
'common.area',
'Area'
)}
</h3>
<select
id="projectArea"
name="area_id"
value={formData.area_id || ""}
value={
formData.area_id ||
''
}
onChange={handleChange}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
>
<option value="">{t('common.none', 'No Area')}</option>
<option value="">
{t(
'common.none',
'No Area'
)}
</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
<option
key={area.id}
value={area.id}
>
{area.name}
</option>
))}
@ -436,20 +503,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.image && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('project.projectImage', 'Project Image')}
{t(
'project.projectImage',
'Project Image'
)}
</h3>
{imagePreview ? (
<div className="mb-3">
<div className="relative inline-block">
<img
src={imagePreview}
src={
imagePreview
}
alt="Project preview"
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
/>
<button
type="button"
onClick={handleRemoveImage}
onClick={
handleRemoveImage
}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
>
×
@ -462,21 +536,41 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
onChange={
handleImageSelect
}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onClick={() =>
fileInputRef.current?.click()
}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{t('project.browseImage', 'Browse Image')}
{t(
'project.browseImage',
'Browse Image'
)}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('project.uploadImageHint', 'Upload an image for your project (max 5MB)')}
{t(
'project.uploadImageHint',
'Upload an image for your project (max 5MB)'
)}
</p>
</div>
)}
@ -484,12 +578,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.priority && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.priority', 'Priority')}
{t(
'forms.priority',
'Priority'
)}
</h3>
<PriorityDropdown
value={formData.priority || "medium"}
onChange={(value: PriorityType) =>
setFormData({ ...formData, priority: value })
value={
formData.priority ||
'medium'
}
onChange={(
value: PriorityType
) =>
setFormData({
...formData,
priority: value,
})
}
/>
</div>
@ -498,12 +603,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.dueDate && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.dueDate', 'Due Date')}
{t(
'forms.dueDate',
'Due Date'
)}
</h3>
<input
type="date"
name="due_date_at"
value={formData.due_date_at || ""}
value={
formData.due_date_at ||
''
}
onChange={handleChange}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
/>
@ -522,13 +633,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Active Status Toggle - First */}
<button
type="button"
onClick={() => toggleSection('active')}
onClick={() =>
toggleSection('active')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.active
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('projects.active', 'Status')}
title={t(
'projects.active',
'Status'
)}
>
<PowerIcon className="h-5 w-5" />
{!formData.active && (
@ -539,7 +655,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Tags Toggle */}
<button
type="button"
onClick={() => toggleSection('tags')}
onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -548,7 +666,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
title={t('forms.tags', 'Tags')}
>
<TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && (
{formData.tags &&
formData.tags.length > 0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
@ -556,7 +675,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Area Toggle */}
<button
type="button"
onClick={() => toggleSection('area')}
onClick={() =>
toggleSection('area')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.area
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -573,13 +694,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Project Image Toggle */}
<button
type="button"
onClick={() => toggleSection('image')}
onClick={() =>
toggleSection('image')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.image
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('project.projectImage', 'Project Image')}
title={t(
'project.projectImage',
'Project Image'
)}
>
<CameraIcon className="h-5 w-5" />
{formData.image_url && (
@ -590,13 +716,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Priority Toggle */}
<button
type="button"
onClick={() => toggleSection('priority')}
onClick={() =>
toggleSection('priority')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.priority
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.priority', 'Priority')}
title={t(
'forms.priority',
'Priority'
)}
>
<ExclamationTriangleIcon className="h-5 w-5" />
{formData.priority !== 'medium' && (
@ -607,13 +738,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Due Date Toggle */}
<button
type="button"
onClick={() => toggleSection('dueDate')}
onClick={() =>
toggleSection('dueDate')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.dueDate
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.dueDate', 'Due Date')}
title={t(
'forms.dueDate',
'Due Date'
)}
>
<CalendarIcon className="h-5 w-5" />
{formData.due_date_at && (
@ -628,7 +764,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{(project && project.id && onDelete) && (
{project && project.id && onDelete && (
<button
type="button"
onClick={handleDeleteClick}
@ -653,10 +789,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onClick={handleSubmit}
disabled={isUploading}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isUploading ? 'opacity-50 cursor-not-allowed' : ''
isUploading
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isUploading ? 'Uploading...' : (project ? t('modals.updateProject', 'Update Project') : t('modals.createProject', 'Create Project'))}
{isUploading
? 'Uploading...'
: project
? t(
'modals.updateProject',
'Update Project'
)
: t(
'modals.createProject',
'Create Project'
)}
</button>
</div>
</div>
@ -672,7 +820,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</>
);
};

View file

@ -1,56 +1,73 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
MagnifyingGlassIcon,
FolderIcon,
Squares2X2Icon,
Bars3Icon,
} from "@heroicons/react/24/solid";
import ConfirmDialog from "./Shared/ConfirmDialog";
import ProjectModal from "./Project/ProjectModal";
import { useStore } from "../store/useStore";
import { fetchGroupedProjects, createProject, updateProject, deleteProject } from "../utils/projectsService";
import { fetchAreas } from "../utils/areasService";
import { useTranslation } from "react-i18next";
import { Project } from "../entities/Project";
import { PriorityType } from "../entities/Task";
import { useSearchParams } from "react-router-dom";
import ProjectItem from "./Project/ProjectItem";
} from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import ProjectModal from './Project/ProjectModal';
import { useStore } from '../store/useStore';
import {
fetchGroupedProjects,
createProject,
updateProject,
deleteProject,
} from '../utils/projectsService';
import { fetchAreas } from '../utils/areasService';
import { useTranslation } from 'react-i18next';
import { Project } from '../entities/Project';
import { PriorityType } from '../entities/Task';
import { useSearchParams } from 'react-router-dom';
import ProjectItem from './Project/ProjectItem';
const getPriorityStyles = (priority: PriorityType) => {
switch (priority) {
case "low":
return { color: "bg-green-500" };
case "medium":
return { color: "bg-yellow-500" };
case "high":
return { color: "bg-red-500" };
case 'low':
return { color: 'bg-green-500' };
case 'medium':
return { color: 'bg-yellow-500' };
case 'high':
return { color: 'bg-red-500' };
default:
return { color: "bg-gray-500" };
return { color: 'bg-gray-500' };
}
};
const Projects: React.FC = () => {
const { t } = useTranslation();
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore);
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore);
const {
areas,
setAreas,
setError: setAreasError,
} = useStore((state) => state.areasStore);
const {
projects,
setProjects,
setLoading: setProjectsLoading,
setError: setProjectsError,
} = useStore((state) => state.projectsStore);
const { isLoading, isError } = useStore((state) => state.projectsStore);
const [groupedProjects, setGroupedProjects] = useState<Record<string, Project[]>>({});
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
const [groupedProjects, setGroupedProjects] = useState<
Record<string, Project[]>
>({});
const [isProjectModalOpen, setIsProjectModalOpen] =
useState<boolean>(false);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(
null
);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
const [viewMode, setViewMode] = useState<"cards" | "list">("cards");
const [searchQuery, setSearchQuery] = useState<string>('');
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
const [searchParams, setSearchParams] = useSearchParams();
const activeFilter = searchParams.get("active") || "all";
// Dispatch global modal events
const areaFilter = searchParams.get("area_id") || "";
const activeFilter = searchParams.get('active') || 'all';
const areaFilter = searchParams.get('area_id') || '';
useEffect(() => {
const loadAreas = async () => {
@ -58,7 +75,7 @@ useEffect(() => {
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error("Failed to fetch areas:", error);
console.error('Failed to fetch areas:', error);
setAreasError(true);
}
};
@ -69,10 +86,13 @@ useEffect(() => {
useEffect(() => {
const loadProjects = async () => {
try {
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error("Failed to fetch projects:", error);
console.error('Failed to fetch projects:', error);
setProjectsError(true);
}
};
@ -88,10 +108,13 @@ useEffect(() => {
} else {
await createProject(project);
}
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error("Error saving project:", error);
console.error('Error saving project:', error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
@ -99,7 +122,6 @@ useEffect(() => {
}
};
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsProjectModalOpen(true);
@ -112,13 +134,16 @@ useEffect(() => {
if (projectToDelete.id !== undefined) {
setProjectsLoading(true);
await deleteProject(projectToDelete.id);
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} else {
console.error("Cannot delete project: ID is undefined.");
console.error('Cannot delete project: ID is undefined.');
}
} catch (error) {
console.error("Error deleting project:", error);
console.error('Error deleting project:', error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
@ -132,39 +157,47 @@ useEffect(() => {
return (project as any).completion_percentage || 0;
};
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const handleActiveFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newActiveFilter === "all") {
params.delete("active");
if (newActiveFilter === 'all') {
params.delete('active');
} else {
params.set("active", newActiveFilter);
params.set('active', newActiveFilter);
}
setSearchParams(params);
};
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const handleAreaFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newAreaFilter === "") {
params.delete("area_id");
if (newAreaFilter === '') {
params.delete('area_id');
} else {
params.set("area_id", newAreaFilter);
params.set('area_id', newAreaFilter);
}
setSearchParams(params);
};
// Apply search filter to the grouped projects from backend
const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<Record<string, Project[]>>(
(acc, areaName) => {
const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<
Record<string, Project[]>
>((acc, areaName) => {
const projectsInArea = groupedProjects[areaName];
// Defensive check: ensure projectsInArea is an array
if (!Array.isArray(projectsInArea)) {
console.warn(`Projects for area "${areaName}" is not an array:`, projectsInArea);
console.warn(
`Projects for area "${areaName}" is not an array:`,
projectsInArea
);
return acc;
}
@ -175,9 +208,7 @@ useEffect(() => {
acc[areaName] = filteredProjects;
}
return acc;
},
{}
);
}, {});
if (isLoading) {
return (
@ -192,7 +223,9 @@ useEffect(() => {
if (isError) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">{t('projects.error')}</div>
<div className="text-red-500 text-lg">
{t('projects.error')}
</div>
</div>
);
}
@ -211,25 +244,25 @@ useEffect(() => {
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 space-y-4 md:space-y-0">
<div className="flex items-center space-x-2">
<button
onClick={() => setViewMode("cards")}
onClick={() => setViewMode('cards')}
className={`p-2 rounded-md focus:outline-none ${
viewMode === "cards"
? "bg-blue-500 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
viewMode === 'cards'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
aria-label={t("projects.cardViewAriaLabel")}
aria-label={t('projects.cardViewAriaLabel')}
>
<Squares2X2Icon className="h-5 w-5" />
</button>
<button
onClick={() => setViewMode("list")}
onClick={() => setViewMode('list')}
className={`p-2 rounded-md focus:outline-none ${
viewMode === "list"
? "bg-blue-500 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
viewMode === 'list'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
aria-label={t("projects.listViewAriaLabel")}
aria-label={t('projects.listViewAriaLabel')}
>
<Bars3Icon className="h-5 w-5" />
</button>
@ -249,9 +282,15 @@ useEffect(() => {
onChange={handleActiveFilterChange}
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="true">{t('projects.filters.active')}</option>
<option value="false">{t('projects.filters.inactive')}</option>
<option value="all">{t('projects.filters.all')}</option>
<option value="true">
{t('projects.filters.active')}
</option>
<option value="false">
{t('projects.filters.inactive')}
</option>
<option value="all">
{t('projects.filters.all')}
</option>
</select>
</div>
@ -268,9 +307,14 @@ useEffect(() => {
onChange={handleAreaFilterChange}
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{t('projects.filters.allAreas')}</option>
<option value="">
{t('projects.filters.allAreas')}
</option>
{areas.map((area) => (
<option key={area.id} value={area.id?.toString()}>
<option
key={area.id}
value={area.id?.toString()}
>
{area.name}
</option>
))}
@ -296,9 +340,9 @@ useEffect(() => {
{/* Projects Grid/List */}
<div
className={`${
viewMode === "cards"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
: "flex flex-col space-y-1"
viewMode === 'cards'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
: 'flex flex-col space-y-1'
}`}
>
{Object.keys(searchFilteredGroupedProjects).length === 0 ? (
@ -306,34 +350,54 @@ useEffect(() => {
{t('projects.noProjectsFound')}
</div>
) : (
Object.keys(searchFilteredGroupedProjects).map((areaName) => (
Object.keys(searchFilteredGroupedProjects).map(
(areaName) => (
<React.Fragment key={areaName}>
<h3 className={`${
viewMode === "cards"
? "col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6"
: "text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 mt-6 border-b border-gray-300 dark:border-gray-600 pb-2"
}`}>
<h3
className={`${
viewMode === 'cards'
? 'col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6'
: 'text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 mt-6 border-b border-gray-300 dark:border-gray-600 pb-2'
}`}
>
{areaName}
</h3>
{searchFilteredGroupedProjects[areaName].map((project) => {
const { color } = getPriorityStyles(project.priority || "low");
{searchFilteredGroupedProjects[
areaName
].map((project) => {
const { color } = getPriorityStyles(
project.priority || 'low'
);
return (
<ProjectItem
key={project.id}
project={project}
viewMode={viewMode}
color={color}
getCompletionPercentage={() => getCompletionPercentage(project)}
getCompletionPercentage={() =>
getCompletionPercentage(
project
)
}
activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
setActiveDropdown={
setActiveDropdown
}
handleEditProject={
handleEditProject
}
setProjectToDelete={
setProjectToDelete
}
setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/>
);
})}
</React.Fragment>
))
)
)
)}
</div>
</div>
@ -349,7 +413,11 @@ useEffect(() => {
onDelete={async (projectId) => {
try {
await deleteProject(projectId);
setProjects(projects.filter((p: Project) => p.id !== projectId));
setProjects(
projects.filter(
(p: Project) => p.id !== projectId
)
);
setIsProjectModalOpen(false);
setProjectToEdit(null);
} catch (error) {
@ -364,7 +432,9 @@ useEffect(() => {
{isConfirmDialogOpen && (
<ConfirmDialog
title={t('modals.deleteProject.title')}
message={t('modals.deleteProject.message', { projectName: projectToDelete?.name })}
message={t('modals.deleteProject.message', {
projectName: projectToDelete?.name,
})}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>

View file

@ -1,5 +1,5 @@
import React from 'react';
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface CollapsibleSectionProps {
title: string;
@ -14,10 +14,12 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
isExpanded,
onToggle,
children,
className = ""
className = '',
}) => {
return (
<div className={`border-b border-gray-200 dark:border-gray-700 ${className}`}>
<div
className={`border-b border-gray-200 dark:border-gray-700 ${className}`}
>
<button
type="button"
onClick={onToggle}
@ -33,12 +35,14 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
)}
</button>
<div className={`transition-all duration-300 ease-in-out ${
isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'
}`}>
<div className="px-4 pb-4">
{children}
</div>
<div
className={`transition-all duration-300 ease-in-out ${
isExpanded
? 'max-h-[500px] opacity-100'
: 'max-h-0 opacity-0 overflow-hidden'
}`}
>
<div className="px-4 pb-4">{children}</div>
</div>
</div>
);

View file

@ -8,14 +8,23 @@ interface ConfirmDialogProps {
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title,
message,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-lg mx-4">
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{title}</h3>
<p className="text-gray-700 dark:text-gray-300 mb-8">{message}</p>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
{title}
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-8">
{message}
</p>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}

View file

@ -1,14 +1,14 @@
import { MoonIcon, SunIcon } from "@heroicons/react/24/solid";
import React, { useEffect, useState } from "react";
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid';
import React, { useEffect, useState } from 'react';
const DarkModeToggle: React.FC = () => {
const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem("darkMode") === "true";
return localStorage.getItem('darkMode') === 'true';
});
useEffect(() => {
document.body.classList.toggle("dark-mode", darkMode);
localStorage.setItem("darkMode", darkMode.toString());
document.body.classList.toggle('dark-mode', darkMode);
localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode]);
return (

View file

@ -1,6 +1,10 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
import {
ChevronLeftIcon,
ChevronRightIcon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
interface DatePickerProps {
value: string;
@ -8,7 +12,6 @@ interface DatePickerProps {
placeholder?: string;
disabled?: boolean;
className?: string;
label?: string;
}
const DatePicker: React.FC<DatePickerProps> = ({
@ -17,17 +20,31 @@ const DatePicker: React.FC<DatePickerProps> = ({
placeholder = 'Select date',
disabled = false,
className = '',
label
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const [currentMonth, setCurrentMonth] = useState(new Date());
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@ -50,7 +67,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
day: 'numeric',
});
};
@ -81,7 +98,10 @@ const DatePicker: React.FC<DatePickerProps> = ({
openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8);
} else {
top = Math.min(window.innerHeight - menuHeight - padding, rect.bottom + 8);
top = Math.min(
window.innerHeight - menuHeight - padding,
rect.bottom + 8
);
}
}
@ -95,25 +115,37 @@ const DatePicker: React.FC<DatePickerProps> = ({
top,
left,
width: Math.max(rect.width, 280), // Minimum width for calendar
openUpward
openUpward,
});
// Set current month based on selected date or today
if (value) {
const selectedDate = parseDate(value);
if (selectedDate && !isNaN(selectedDate.getTime())) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setCurrentMonth(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
1
)
);
}
} else {
setCurrentMonth(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
setCurrentMonth(
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
);
}
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@ -135,7 +167,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(prev => {
setCurrentMonth((prev) => {
const newMonth = new Date(prev);
if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1);
@ -177,7 +209,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
const isSelected = (date: Date) => {
if (!value) return false;
const selectedDate = parseDate(value);
return selectedDate && date.toDateString() === selectedDate.toDateString();
return (
selectedDate && date.toDateString() === selectedDate.toDateString()
);
};
useEffect(() => {
@ -193,11 +227,16 @@ const DatePicker: React.FC<DatePickerProps> = ({
}, [isOpen]);
return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}>
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button
type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
@ -206,7 +245,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
}}
disabled={disabled}
>
<span className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}>
<span
className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{formatDisplayDate(value)}
</span>
<div className="flex items-center space-x-1">
@ -223,7 +264,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
</div>
</button>
{isOpen && createPortal(
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 date-picker-menu"
@ -244,7 +286,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
<ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{months[currentMonth.getMonth()]} {currentMonth.getFullYear()}
{months[currentMonth.getMonth()]}{' '}
{currentMonth.getFullYear()}
</span>
<button
type="button"
@ -259,8 +302,11 @@ const DatePicker: React.FC<DatePickerProps> = ({
<div className="p-3">
{/* Day Headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{days.map(day => (
<div key={day} className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1">
{days.map((day) => (
<div
key={day}
className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"
>
{day}
</div>
))}
@ -273,7 +319,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
{date && (
<button
type="button"
onClick={() => handleDateSelect(date)}
onClick={() =>
handleDateSelect(date)
}
className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date)
? 'bg-blue-600 text-white'

View file

@ -7,4 +7,3 @@ const LoadingScreen: React.FC = () => (
);
export default LoadingScreen;

View file

@ -9,11 +9,29 @@ interface MarkdownRendererProps {
className?: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = '',
}) => {
useEffect(() => {
// Configure highlight.js
hljs.configure({
languages: ['javascript', 'typescript', 'python', 'java', 'css', 'html', 'json', 'bash', 'sql', 'yaml', 'xml', 'dockerfile', 'nginx', 'apache']
languages: [
'javascript',
'typescript',
'python',
'java',
'css',
'html',
'json',
'bash',
'sql',
'yaml',
'xml',
'dockerfile',
'nginx',
'apache',
],
});
// Manual highlighting for any missed code blocks
@ -31,64 +49,183 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
<div className={`markdown-content ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
rehypePlugins={[
[rehypeHighlight, { detect: true, ignoreMissing: true }],
]}
components={{
// Customize heading styles
h1: ({...props}) => <h1 className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100" {...props} />,
h2: ({...props}) => <h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100" {...props} />,
h3: ({...props}) => <h3 className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
h4: ({...props}) => <h4 className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
h5: ({...props}) => <h5 className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
h6: ({...props}) => <h6 className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
h1: ({ ...props }) => (
<h1
className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h2: ({ ...props }) => (
<h2
className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h3: ({ ...props }) => (
<h3
className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h4: ({ ...props }) => (
<h4
className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h5: ({ ...props }) => (
<h5
className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h6: ({ ...props }) => (
<h6
className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
// Customize paragraph styles
p: ({...props}) => <p className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed" {...props} />,
p: ({ ...props }) => (
<p
className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed"
{...props}
/>
),
// Customize list styles
ul: ({...props}) => <ul className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />,
ol: ({...props}) => <ol className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />,
ul: ({ ...props }) => (
<ul
className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300"
{...props}
/>
),
li: ({ ...props }) => <li className="ml-4" {...props} />,
// Customize link styles
a: ({...props}) => <a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />,
a: ({ ...props }) => (
<a
className="text-blue-600 dark:text-blue-400 hover:underline"
{...props}
/>
),
// Customize code styles
code: ({ className, children, ...props }) => {
// Check if this is a code block (has language class) or inline code
const isCodeBlock = className && className.startsWith('language-');
const isCodeBlock =
className && className.startsWith('language-');
if (isCodeBlock) {
// This is a code block - add hljs class to ensure our styles apply
return <code className={`${className} hljs`} {...props}>{children}</code>;
return (
<code
className={`${className} hljs`}
{...props}
>
{children}
</code>
);
} else {
// This is inline code - apply our custom styling
// Check if parent is a pre element - if so, this might be a code block without language
const parentIsPre = (props as any).node?.parent?.tagName === 'pre';
// eslint-disable-next-line react/prop-types
const node = (props as any).node;
// eslint-disable-next-line react/prop-types
const parentIsPre = node?.parent?.tagName === 'pre';
if (parentIsPre) {
return <code className="hljs" {...props}>{children}</code>;
return (
<code className="hljs" {...props}>
{children}
</code>
);
}
return <code className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100" {...props}>{children}</code>;
return (
<code
className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100"
{...props}
>
{children}
</code>
);
}
},
pre: ({...props}) => <pre className="mb-4 rounded-lg overflow-x-auto" {...props} />,
pre: ({ ...props }) => (
<pre
className="mb-4 rounded-lg overflow-x-auto"
{...props}
/>
),
// Customize blockquote styles
blockquote: ({...props}) => <blockquote className="mb-4 pl-4 border-l-4 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400" {...props} />,
blockquote: ({ ...props }) => (
<blockquote
className="mb-4 pl-4 border-l-4 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400"
{...props}
/>
),
// Customize table styles
table: ({...props}) => <table className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600" {...props} />,
thead: ({...props}) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
th: ({...props}) => <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-semibold text-gray-900 dark:text-gray-100" {...props} />,
td: ({...props}) => <td className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-gray-700 dark:text-gray-300" {...props} />,
table: ({ ...props }) => (
<table
className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600"
{...props}
/>
),
thead: ({ ...props }) => (
<thead
className="bg-gray-100 dark:bg-gray-800"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-semibold text-gray-900 dark:text-gray-100"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-gray-700 dark:text-gray-300"
{...props}
/>
),
// Customize horizontal rule
hr: ({...props}) => <hr className="my-6 border-gray-300 dark:border-gray-600" {...props} />,
hr: ({ ...props }) => (
<hr
className="my-6 border-gray-300 dark:border-gray-600"
{...props}
/>
),
// Customize strong/bold text
strong: ({...props}) => <strong className="font-semibold text-gray-900 dark:text-gray-100" {...props} />,
strong: ({ ...props }) => (
<strong
className="font-semibold text-gray-900 dark:text-gray-100"
{...props}
/>
),
// Customize italic text
em: ({...props}) => <em className="italic text-gray-700 dark:text-gray-300" {...props} />
em: ({ ...props }) => (
<em
className="italic text-gray-700 dark:text-gray-300"
{...props}
/>
),
}}
>
{content}

View file

@ -4,7 +4,7 @@ const NotFound: React.FC = () => {
return (
<div>
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<p>The page you&apos;re looking for doesn&apos;t exist.</p>
</div>
);
};

View file

@ -19,10 +19,15 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
max = 99,
placeholder = 'Select number',
disabled = false,
className = ''
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -41,21 +46,26 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
openUpward,
});
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@ -82,14 +92,19 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
};
}, [isOpen]);
const selectedOption = options.find(option => option.value === value);
const selectedOption = options.find((option) => option.value === value);
return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}>
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button
type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
@ -101,10 +116,13 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDownIcon className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDownIcon
className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && createPortal(
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto number-dropdown-menu"
@ -132,7 +150,9 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span>
<span className="text-blue-600 dark:text-blue-400">
</span>
)}
</button>
))}

View file

@ -1,5 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayIcon, PauseIcon, ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/outline';
import {
PlayIcon,
PauseIcon,
ArrowPathIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
interface PomodoroTimerProps {
@ -37,8 +42,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
// If timer was running, calculate how much time has passed
if (state.isRunning && state.startTime) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const newTimeLeft = Math.max(0, state.timeLeft - elapsed);
const elapsed = Math.floor(
(Date.now() - state.startTime) / 1000
);
const newTimeLeft = Math.max(
0,
state.timeLeft - elapsed
);
setTimeLeft(newTimeLeft);
if (newTimeLeft > 0) {
setIsRunning(true);
@ -59,7 +69,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
isActive,
timeLeft,
isRunning,
startTime: isRunning ? Date.now() - (DEFAULT_TIME - timeLeft) * 1000 : undefined
startTime: isRunning
? Date.now() - (DEFAULT_TIME - timeLeft) * 1000
: undefined,
};
localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state));
}, [isActive, timeLeft, isRunning]);
@ -67,7 +79,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
setTimeLeft((prev) => {
if (prev <= 1) {
setIsRunning(false);
setShowCompletionMessage(true);
@ -157,7 +169,10 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
if (!isActive) {
return (
<div className={`flex items-center ${className}`} onClick={handleTomatoClick}>
<div
className={`flex items-center ${className}`}
onClick={handleTomatoClick}
>
<TomatoIcon />
</div>
);
@ -173,7 +188,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
<button
onClick={handlePlayPause}
className="flex items-center justify-center p-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
aria-label={isRunning ? t('pomodoro.pause') : t('pomodoro.play')}
aria-label={
isRunning ? t('pomodoro.pause') : t('pomodoro.play')
}
>
{isRunning ? (
<PauseIcon className="h-3 w-3" />
@ -203,9 +220,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
{showCompletionMessage && (
<div className="absolute top-full mt-2 right-0 bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200 px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
<div className="flex items-center space-x-2 mb-2">
<span className="text-sm font-medium">🍅 {t('pomodoro.complete')}</span>
<span className="text-sm font-medium">
🍅 {t('pomodoro.complete')}
</span>
</div>
<p className="text-xs mb-3">{t('pomodoro.completeMessage')}</p>
<p className="text-xs mb-3">
{t('pomodoro.completeMessage')}
</p>
<button
onClick={() => {
setShowCompletionMessage(false);

View file

@ -1,6 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline';
import {
ChevronDownIcon,
ArrowDownIcon,
ArrowUpIcon,
FireIcon,
} from '@heroicons/react/24/outline';
import { PriorityType } from '../../entities/Task';
import { useTranslation } from 'react-i18next';
@ -9,16 +14,42 @@ interface PriorityDropdownProps {
onChange: (value: PriorityType) => void;
}
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation();
const priorities = [
{ value: 'low', label: t('priority.low', 'Low'), icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'medium', label: t('priority.medium', 'Medium'), icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'high', label: t('priority.high', 'High'), icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
{
value: 'low',
label: t('priority.low', 'Low'),
icon: (
<ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'medium',
label: t('priority.medium', 'Medium'),
icon: (
<ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'high',
label: t('priority.high', 'High'),
icon: (
<FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
];
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -29,13 +60,14 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
const spaceAbove = rect.top;
const menuHeight = 120;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
openUpward,
});
}
setIsOpen(!isOpen);
@ -69,10 +101,13 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
};
}, [isOpen]);
const selectedPriority = priorities.find(p => p.value === value);
const selectedPriority = priorities.find((p) => p.value === value);
return (
<div ref={dropdownRef} className="relative inline-block text-left w-full">
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -80,12 +115,17 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
>
<span className="flex items-center space-x-2">
{selectedPriority ? selectedPriority.icon : ''}
<span>{selectedPriority ? selectedPriority.label : t('forms.priority', 'Select Priority')}</span>
<span>
{selectedPriority
? selectedPriority.label
: t('forms.priority', 'Select Priority')}
</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
{isOpen && createPortal(
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600"
@ -98,11 +138,14 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
{priorities.map((priority) => (
<button
key={priority.value}
onClick={() => handleSelect(priority.value as PriorityType)}
onClick={() =>
handleSelect(priority.value as PriorityType)
}
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md"
>
<span className="flex items-center space-x-2">
{priority.icon} <span>{priority.label}</span>
{priority.icon}{' '}
<span>{priority.label}</span>
</span>
</button>
))}

View file

@ -1,6 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDownIcon, ArrowPathIcon, CalendarDaysIcon, ClockIcon } from '@heroicons/react/24/outline';
import {
ChevronDownIcon,
ArrowPathIcon,
CalendarDaysIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import { RecurrenceType } from '../../entities/Task';
import { useTranslation } from 'react-i18next';
@ -9,20 +14,64 @@ interface RecurrenceDropdownProps {
onChange: (value: RecurrenceType) => void;
}
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange }) => {
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation();
const recurrenceOptions = [
{ value: 'none', label: t('recurrence.none', 'No repeat'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'daily', label: t('recurrence.daily', 'Daily'), icon: <ArrowPathIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'monthly_weekday', label: t('recurrence.monthlyWeekday', 'Monthly on weekday'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'monthly_last_day', label: t('recurrence.monthlyLastDay', 'Monthly on last day'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
{
value: 'none',
label: t('recurrence.none', 'No repeat'),
icon: (
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'daily',
label: t('recurrence.daily', 'Daily'),
icon: (
<ArrowPathIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'weekly',
label: t('recurrence.weekly', 'Weekly'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly',
label: t('recurrence.monthly', 'Monthly'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly_weekday',
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly_last_day',
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
];
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -33,20 +82,24 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
const spaceAbove = rect.top;
const menuHeight = 240;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
openUpward,
});
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@ -68,10 +121,13 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
};
}, [isOpen]);
const selectedRecurrence = recurrenceOptions.find(r => r.value === value);
const selectedRecurrence = recurrenceOptions.find((r) => r.value === value);
return (
<div ref={dropdownRef} className="relative inline-block text-left w-full">
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -79,12 +135,20 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
>
<span className="flex items-center space-x-2">
{selectedRecurrence ? selectedRecurrence.icon : ''}
<span>{selectedRecurrence ? selectedRecurrence.label : t('forms.task.labels.recurrenceType', 'Select Recurrence')}</span>
<span>
{selectedRecurrence
? selectedRecurrence.label
: t(
'forms.task.labels.recurrenceType',
'Select Recurrence'
)}
</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
{isOpen && createPortal(
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600"
@ -97,11 +161,16 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
{recurrenceOptions.map((recurrence) => (
<button
key={recurrence.value}
onClick={() => handleSelect(recurrence.value as RecurrenceType)}
onClick={() =>
handleSelect(
recurrence.value as RecurrenceType
)
}
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md"
>
<span className="flex items-center space-x-2">
{recurrence.icon} <span>{recurrence.label}</span>
{recurrence.icon}{' '}
<span>{recurrence.label}</span>
</span>
</button>
))}

View file

@ -22,10 +22,15 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
options,
placeholder = 'Select option',
disabled = false,
className = ''
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -38,21 +43,26 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
openUpward,
});
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@ -79,14 +89,19 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
};
}, [isOpen]);
const selectedOption = options.find(option => option.value === value);
const selectedOption = options.find((option) => option.value === value);
return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}>
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button
type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
@ -98,10 +113,13 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDownIcon className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDownIcon
className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && createPortal(
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto recurrence-dropdown-menu"
@ -129,7 +147,9 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span>
<span className="text-blue-600 dark:text-blue-400">
</span>
)}
</button>
))}

View file

@ -1,5 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import {
ChevronDownIcon,
MinusIcon,
ClockIcon,
CheckCircleIcon,
ArchiveBoxIcon,
} from '@heroicons/react/24/outline';
import { StatusType } from '../../entities/Task';
import { useTranslation } from 'react-i18next';
@ -12,10 +18,34 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const statuses = [
{ value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'done', label: t('status.done', 'Done'), icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'archived', label: t('status.archived', 'Archived'), icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{
value: 'not_started',
label: t('status.notStarted', 'Not Started'),
icon: (
<MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'in_progress',
label: t('status.inProgress', 'In Progress'),
icon: (
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'done',
label: t('status.done', 'Done'),
icon: (
<CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'archived',
label: t('status.archived', 'Archived'),
icon: (
<ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
];
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -25,7 +55,10 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@ -47,10 +80,13 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
};
}, [isOpen]);
const selectedStatus = statuses.find(s => s.value === value);
const selectedStatus = statuses.find((s) => s.value === value);
return (
<div ref={dropdownRef} className="relative inline-block text-left w-full">
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -58,7 +94,11 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
>
<span className="flex items-center space-x-2">
{selectedStatus ? selectedStatus.icon : ''}
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span>
<span>
{selectedStatus
? selectedStatus.label
: 'Select Status'}
</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
@ -68,7 +108,9 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
{statuses.map((status) => (
<button
key={status.value}
onClick={() => handleSelect(status.value as StatusType)}
onClick={() =>
handleSelect(status.value as StatusType)
}
className="flex items-center justify-between space-x-2 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
>
<span className="flex items-center space-x-2">

View file

@ -7,9 +7,7 @@ interface SwitchProps {
const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => {
return (
<div
className="flex items-center space-x-2"
>
<div className="flex items-center space-x-2">
<div
className={`w-12 h-6 flex items-center rounded-full p-1 cursor-pointer transition-all duration-300 ${
isChecked ? 'bg-blue-600' : 'bg-gray-300'

View file

@ -7,15 +7,22 @@ interface ToastContextProps {
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toastMessage, setToastMessage] = useState<string | React.ReactNode | null>(null);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [toastMessage, setToastMessage] = useState<
string | React.ReactNode | null
>(null);
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const showSuccessToast = useCallback((message: string | React.ReactNode) => {
const showSuccessToast = useCallback(
(message: string | React.ReactNode) => {
setToastMessage(message);
setToastType('success');
setTimeout(() => setToastMessage(null), 4000);
}, []);
},
[]
);
const showErrorToast = useCallback((message: string | React.ReactNode) => {
setToastMessage(message);
@ -26,7 +33,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children}
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={() => setToastMessage(null)}
/>
)}
</ToastContext.Provider>
);
};
@ -39,7 +52,11 @@ export const useToast = () => {
return context;
};
const Toast: React.FC<{ message: string | React.ReactNode; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => {
const Toast: React.FC<{
message: string | React.ReactNode;
type: 'success' | 'error';
onClose: () => void;
}> = ({ message, type, onClose }) => {
return (
<div
className={`fixed top-20 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
@ -48,7 +65,10 @@ const Toast: React.FC<{ message: string | React.ReactNode; type: 'success' | 'er
>
<div className="flex items-center">
<div className="flex-1">{message}</div>
<button onClick={onClose} className="ml-4 text-xl leading-none hover:opacity-75">
<button
onClick={onClose}
className="ml-4 text-xl leading-none hover:opacity-75"
>
&times;
</button>
</div>

View file

@ -15,7 +15,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
label,
description,
disabled = false,
className = ''
className = '',
}) => {
const handleToggle = () => {
if (!disabled) {
@ -33,7 +33,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${checked
${
checked
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-600'
}
@ -64,11 +65,13 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
{label}
</label>
{description && (
<p className={`text-xs mt-1 ${
<p
className={`text-xs mt-1 ${
disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-500 dark:text-gray-400'
}`}>
}`}
>
{description}
</p>
)}

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { extractTitleFromText, UrlTitleResult } from '../../utils/urlService';
import { LinkIcon, XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline';
interface UrlPreviewProps {
text: string;
@ -58,7 +58,9 @@ const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Loading preview...</span>
<span className="text-sm text-gray-600 dark:text-gray-300">
Loading preview...
</span>
</div>
</div>
);
@ -94,21 +96,27 @@ const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
)}
</div>
<div className="flex-1 min-w-0 pr-6">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100" style={{
<div
className="text-sm font-medium text-gray-900 dark:text-gray-100"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
overflow: 'hidden',
}}
>
{preview.title || 'Untitled'}
</div>
{preview.description && (
<div className="text-xs text-gray-600 dark:text-gray-300 mt-1" style={{
<div
className="text-xs text-gray-600 dark:text-gray-300 mt-1"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
overflow: 'hidden',
}}
>
{preview.description}
</div>
)}

View file

@ -50,7 +50,7 @@ const Sidebar: React.FC<SidebarProps> = ({
setIsDropdownOpen(!isDropdownOpen);
};
const handleNavClick = (path: string, title: string, icon: JSX.Element) => {
const handleNavClick = (path: string, title: string) => {
navigate(path, { state: { title } });
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);

View file

@ -1,8 +1,8 @@
import React from "react";
import { Squares2X2Icon, PlusCircleIcon } from "@heroicons/react/24/outline";
import { Location } from "react-router-dom";
import { Area } from "../../entities/Area";
import { useTranslation } from "react-i18next";
import React from 'react';
import { Squares2X2Icon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { Location } from 'react-router-dom';
import { Area } from '../../entities/Area';
import { useTranslation } from 'react-i18next';
interface SidebarAreasProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
@ -20,8 +20,8 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
const { t } = useTranslation();
const isActiveArea = (path: string) => {
return location.pathname === path
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300";
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
return (
@ -30,12 +30,12 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
{/* "AREAS" Title with Add Button */}
<li
className={`flex justify-between items-center px-4 py-2 rounded-md uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveArea(
"/areas"
'/areas'
)}`}
onClick={() =>
handleNavClick(
"/areas",
"Areas",
'/areas',
'Areas',
<Squares2X2Icon className="h-5 w-5 mr-2" />
)
}

View file

@ -33,7 +33,6 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
isDarkMode,
toggleDarkMode,
isSidebarOpen,
setIsSidebarOpen,
openTaskModal,
openProjectModal,
openNoteModal,
@ -51,7 +50,10 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
@ -135,18 +137,51 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
};
const dropdownItems = [
{ label: 'Inbox', translationKey: 'dropdown.inbox', icon: <InboxIcon className="h-5 w-5 mr-2" />, shortcut: '⌃I' },
{ label: 'Task', translationKey: 'dropdown.task', icon: <CheckIcon className="h-5 w-5 mr-2" />, shortcut: '⌃T' },
{ label: 'Project', translationKey: 'dropdown.project', icon: <FolderIcon className="h-5 w-5 mr-2" />, shortcut: '⌃P' },
{ label: 'Note', translationKey: 'dropdown.note', icon: <BookOpenIcon className="h-5 w-5 mr-2" />, shortcut: '⌃N' },
{ label: 'Area', translationKey: 'dropdown.area', icon: <Squares2X2Icon className="h-5 w-5 mr-2" />, shortcut: '⌃A' },
{ label: 'Tag', translationKey: 'dropdown.tag', icon: <TagIcon className="h-5 w-5 mr-2" />, shortcut: '⌃G' },
{
label: 'Inbox',
translationKey: 'dropdown.inbox',
icon: <InboxIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃I',
},
{
label: 'Task',
translationKey: 'dropdown.task',
icon: <CheckIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃T',
},
{
label: 'Project',
translationKey: 'dropdown.project',
icon: <FolderIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃P',
},
{
label: 'Note',
translationKey: 'dropdown.note',
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃N',
},
{
label: 'Area',
translationKey: 'dropdown.area',
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
shortcut: '⌃A',
},
{
label: 'Tag',
translationKey: 'dropdown.tag',
icon: <TagIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃G',
},
];
return (
<div className="mt-auto p-3">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
{isSidebarOpen && (
<div className="flex items-center justify-between" ref={dropdownRef}>
<div
className="flex items-center justify-between"
ref={dropdownRef}
>
{/* Plus Icon Button - Left */}
<div className="relative">
<button
@ -164,21 +199,35 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
{isDropdownOpen && (
<div className="absolute bottom-full left-0 mb-2 w-52 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{dropdownItems.map(({ label, translationKey, icon, shortcut }) => (
{dropdownItems.map(
({
label,
translationKey,
icon,
shortcut,
}) => (
<button
key={label}
onClick={() => handleDropdownSelect(label)}
onClick={() =>
handleDropdownSelect(
label
)
}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between transition-colors duration-150"
>
<div className="flex items-center">
{icon}
{t(translationKey, label)}
{t(
translationKey,
label
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded opacity-60">
{shortcut}
</span>
</button>
))}
)
)}
</div>
</div>
)}

View file

@ -3,9 +3,7 @@ import { Location } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CalendarDaysIcon,
ArrowRightCircleIcon,
InboxIcon,
CheckCircleIcon,
ListBulletIcon,
ClockIcon,
} from '@heroicons/react/24/solid';
@ -18,7 +16,10 @@ interface SidebarNavProps {
isDarkMode: boolean;
}
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => {
const SidebarNav: React.FC<SidebarNavProps> = ({
handleNavClick,
location,
}) => {
const { t } = useTranslation();
const store = useStore();
@ -31,10 +32,28 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
}, []);
const navLinks = [
{ path: '/inbox', title: t('sidebar.inbox', 'Inbox'), icon: <InboxIcon className="h-5 w-5" /> },
{ path: '/today', title: t('sidebar.today', 'Today'), icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=upcoming' },
{ path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: <ListBulletIcon className="h-5 w-5" /> },
{
path: '/inbox',
title: t('sidebar.inbox', 'Inbox'),
icon: <InboxIcon className="h-5 w-5" />,
},
{
path: '/today',
title: t('sidebar.today', 'Today'),
icon: <CalendarDaysIcon className="h-5 w-5" />,
query: 'type=today',
},
{
path: '/tasks?type=upcoming',
title: t('sidebar.upcoming', 'Upcoming'),
icon: <ClockIcon className="h-5 w-5" />,
query: 'type=upcoming',
},
{
path: '/tasks',
title: t('sidebar.allTasks', 'All Tasks'),
icon: <ListBulletIcon className="h-5 w-5" />,
},
];
const isActive = (path: string, query?: string) => {
@ -48,7 +67,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
// Regular case for /tasks with query params
const isPathMatch = location.pathname === '/tasks';
const isQueryMatch = query ? location.search.includes(query) : location.search === '';
const isQueryMatch = query
? location.search.includes(query)
: location.search === '';
return isPathMatch && isQueryMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
@ -60,7 +81,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
<React.Fragment key={link.path}>
<li>
<button
onClick={() => handleNavClick(link.path, link.title, link.icon)}
onClick={() =>
handleNavClick(link.path, link.title, link.icon)
}
className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
link.path,
link.query
@ -72,7 +95,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
</div>
{link.path === '/inbox' && inboxItemsCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white bg-blue-500 rounded-full">
{inboxItemsCount > 99 ? '99+' : inboxItemsCount}
{inboxItemsCount > 99
? '99+'
: inboxItemsCount}
</span>
)}
</button>

View file

@ -31,7 +31,13 @@ const SidebarNotes: React.FC<SidebarNotesProps> = ({
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveNote(
'/notes'
)}`}
onClick={() => handleNavClick('/notes', 'Notes', <BookOpenIcon className="h-5 w-5 mr-2" />)}
onClick={() =>
handleNavClick(
'/notes',
'Notes',
<BookOpenIcon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />

View file

@ -29,7 +29,13 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
className={`flex justify-between items-center px-4 py-2 uppercase rounded-md text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveProject(
'/projects'
)}`}
onClick={() => handleNavClick('/projects?active=true', 'Projects', <FolderIcon className="h-5 w-5 mr-2" />)}
onClick={() =>
handleNavClick(
'/projects?active=true',
'Projects',
<FolderIcon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />

View file

@ -33,7 +33,13 @@ const SidebarTags: React.FC<SidebarTagsProps> = ({
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveTag(
'/tags'
)}`}
onClick={() => handleNavClick('/tags', 'Tags', <TagIcon className="h-5 w-5 mr-2" />)}
onClick={() =>
handleNavClick(
'/tags',
'Tags',
<TagIcon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" />

View file

@ -7,7 +7,7 @@ import {
BookOpenIcon,
FolderIcon,
PencilSquareIcon,
TrashIcon
TrashIcon,
} from '@heroicons/react/24/solid';
import { Task } from '../../entities/Task';
import { Note } from '../../entities/Note';
@ -34,24 +34,34 @@ const TagDetails: React.FC = () => {
// State for ProjectItem components
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [, setProjectToDelete] = useState<Project | null>(
null
);
const [, setIsConfirmDialogOpen] =
useState<boolean>(false);
const navigate = useNavigate();
useEffect(() => {
const fetchTagData = async () => {
try {
// First fetch tag details
const tagResponse = await fetch(`/api/tag/${encodeURIComponent(identifier!)}`);
const tagResponse = await fetch(
`/api/tag/${encodeURIComponent(identifier!)}`
);
if (tagResponse.ok) {
const tagData = await tagResponse.json();
setTag(tagData);
// Now fetch entities that have this tag using the tag name
const [tasksResponse, notesResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks?tag=${encodeURIComponent(tagData.name)}`),
fetch(`/api/notes?tag=${encodeURIComponent(tagData.name)}`),
fetch(`/api/projects`) // Projects API doesn't support tag filtering yet
const [tasksResponse, notesResponse, projectsResponse] =
await Promise.all([
fetch(
`/api/tasks?tag=${encodeURIComponent(tagData.name)}`
),
fetch(
`/api/notes?tag=${encodeURIComponent(tagData.name)}`
),
fetch(`/api/projects`), // Projects API doesn't support tag filtering yet
]);
if (tasksResponse.ok) {
@ -67,9 +77,14 @@ const TagDetails: React.FC = () => {
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
// Filter projects client-side since API doesn't support tag filtering
const allProjects = projectsData.projects || projectsData || [];
const filteredProjects = allProjects.filter((project: any) =>
project.tags && project.tags.some((tag: any) => tag.name === tagData.name)
const allProjects =
projectsData.projects || projectsData || [];
const filteredProjects = allProjects.filter(
(project: any) =>
project.tags &&
project.tags.some(
(tag: any) => tag.name === tagData.name
)
);
setProjects(filteredProjects);
}
@ -77,7 +92,7 @@ const TagDetails: React.FC = () => {
const tagError = await tagResponse.json();
setError(tagError.error || 'Failed to fetch tag.');
}
} catch (err) {
} catch {
setError(t('tags.error'));
} finally {
setLoading(false);
@ -90,8 +105,8 @@ const TagDetails: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
});
@ -103,65 +118,73 @@ const TagDetails: React.FC = () => {
);
}
} catch (error) {
console.error("Error updating task:", error);
console.error('Error updating task:', error);
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
method: 'DELETE',
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
}
} catch (error) {
console.error("Error deleting task:", error);
console.error('Error deleting task:', error);
}
};
const handleToggleToday = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}/today`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const updatedTask = await response.json();
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task
task.id === taskId
? {
...task,
today: updatedTask.today,
today_move_count:
updatedTask.today_move_count,
}
: task
)
);
}
} catch (error) {
console.error("Error toggling today status:", error);
console.error('Error toggling today status:', error);
}
};
// Project handlers
const handleEditProject = (project: Project) => {
// For now, just log - could add modal later
console.log("Edit project:", project);
console.log('Edit project:', project);
};
const getCompletionPercentage = (project: Project) => {
return (project as any).completion_percentage || 0;
};
const getPriorityStyles = (priority: string) => {
switch (priority) {
case "low":
return { color: "bg-green-500" };
case "medium":
return { color: "bg-yellow-500" };
case "high":
return { color: "bg-red-500" };
case 'low':
return { color: 'bg-green-500' };
case 'medium':
return { color: 'bg-yellow-500' };
case 'high':
return { color: 'bg-red-500' };
default:
return { color: "bg-gray-500" };
return { color: 'bg-gray-500' };
}
};
@ -180,7 +203,11 @@ const TagDetails: React.FC = () => {
}
if (!tag) {
return <div className="text-gray-700 dark:text-gray-300 p-4">{t('tags.notFound')}</div>;
return (
<div className="text-gray-700 dark:text-gray-300 p-4">
{t('tags.notFound')}
</div>
);
}
return (
@ -200,8 +227,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center">
<CheckIcon className="h-8 w-8 text-blue-500 mr-3" />
<div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{tasks.length}</p>
<p className="text-gray-600 dark:text-gray-400">{t('tasks.title')}</p>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
{tasks.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('tasks.title')}
</p>
</div>
</div>
</div>
@ -209,8 +240,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center">
<BookOpenIcon className="h-8 w-8 text-green-500 mr-3" />
<div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{notes.length}</p>
<p className="text-gray-600 dark:text-gray-400">{t('notes.title')}</p>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
{notes.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('notes.title')}
</p>
</div>
</div>
</div>
@ -218,8 +253,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center">
<FolderIcon className="h-8 w-8 text-purple-500 mr-3" />
<div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{projects.length}</p>
<p className="text-gray-600 dark:text-gray-400">{t('projects.title')}</p>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
{projects.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('projects.title')}
</p>
</div>
</div>
</div>
@ -255,7 +294,9 @@ const TagDetails: React.FC = () => {
<li
key={note.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
onMouseEnter={() => setHoveredNoteId(note.id || null)}
onMouseEnter={() =>
setHoveredNoteId(note.id || null)
}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
@ -267,14 +308,23 @@ const TagDetails: React.FC = () => {
{note.title}
</Link>
{/* Tags */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
{((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length > 0)) && (
<>
{(note.tags || note.Tags || []).map((noteTag) => (
{(
note.tags ||
note.Tags ||
[]
).map((noteTag) => (
<button
key={noteTag.id}
onClick={(e) => {
e.preventDefault();
navigate(`/tag/${encodeURIComponent(noteTag.name)}`);
navigate(
`/tag/${encodeURIComponent(noteTag.name)}`
);
}}
className="flex items-center space-x-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
@ -290,7 +340,9 @@ const TagDetails: React.FC = () => {
</div>
<div className="flex space-x-2">
<button
onClick={() => console.log("Edit note:", note)}
onClick={() =>
console.log('Edit note:', note)
}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Edit ${note.title}`}
title={`Edit ${note.title}`}
@ -298,7 +350,12 @@ const TagDetails: React.FC = () => {
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => console.log("Delete note:", note)}
onClick={() =>
console.log(
'Delete note:',
note
)
}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Delete ${note.title}`}
title={`Delete ${note.title}`}
@ -321,19 +378,25 @@ const TagDetails: React.FC = () => {
</h3>
<div className="flex flex-col space-y-1">
{projects.map((project) => {
const { color } = getPriorityStyles(project.priority || "low");
const { color } = getPriorityStyles(
project.priority || 'low'
);
return (
<ProjectItem
key={project.id}
project={project}
viewMode="list"
color={color}
getCompletionPercentage={() => getCompletionPercentage(project)}
getCompletionPercentage={() =>
getCompletionPercentage(project)
}
activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/>
);
})}
@ -342,11 +405,16 @@ const TagDetails: React.FC = () => {
)}
{/* Empty State */}
{tasks.length === 0 && notes.length === 0 && projects.length === 0 && (
{tasks.length === 0 &&
notes.length === 0 &&
projects.length === 0 && (
<div className="text-center py-8">
<TagIcon className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 text-lg">
{t('tags.noItemsWithTag', `No items found with the tag "${tag.name}"`)}
{t(
'tags.noItemsWithTag',
`No items found with the tag "${tag.name}"`
)}
</p>
</div>
)}

View file

@ -8,7 +8,11 @@ interface TagInputProps {
availableTags: Tag[];
}
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => {
const TagInput: React.FC<TagInputProps> = ({
initialTags,
onTagsChange,
availableTags,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []);
@ -39,7 +43,8 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
return;
}
const filtered = availableTags.filter(tag =>
const filtered = availableTags.filter(
(tag) =>
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.includes(tag.name)
);
@ -77,13 +82,18 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex(prev => (prev < filteredTags.length - 1 ? prev + 1 : prev));
setHighlightedIndex((prev) =>
prev < filteredTags.length - 1 ? prev + 1 : prev
);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev));
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredTags.length) {
if (
highlightedIndex >= 0 &&
highlightedIndex < filteredTags.length
) {
selectTag(filteredTags[highlightedIndex].name);
} else if (inputValue.trim()) {
addNewTag(inputValue.trim());
@ -186,7 +196,9 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
type="button"
onClick={() => selectTag(tag.name)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${
highlightedIndex === index ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'
highlightedIndex === index
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'
}`}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseLeave={() => setHighlightedIndex(-1)}
@ -197,7 +209,10 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
<>
{inputValue.length > 0 && (
<span className="font-semibold">
{tag.name.substring(0, inputValue.length)}
{tag.name.substring(
0,
inputValue.length
)}
</span>
)}
{tag.name.substring(inputValue.length)}
@ -215,7 +230,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
role="option"
>
+ Create "{inputValue.trim()}"
+ Create &quot;{inputValue.trim()}&quot;
</button>
)}
</div>

View file

@ -83,7 +83,9 @@ const TagModal: React.FC<TagModalProps> = ({
const handleSubmit = async () => {
if (!formData.name.trim()) {
showErrorToast(t('errors.tagNameRequired', 'Tag name is required.'));
showErrorToast(
t('errors.tagNameRequired', 'Tag name is required.')
);
return;
}
@ -92,12 +94,16 @@ const TagModal: React.FC<TagModalProps> = ({
try {
await onSave(formData); // Wait for the save operation to complete
if (tag) {
showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!'));
showSuccessToast(
t('success.tagUpdated', 'Tag updated successfully!')
);
} else {
showSuccessToast(t('success.tagCreated', 'Tag created successfully!'));
showSuccessToast(
t('success.tagCreated', 'Tag created successfully!')
);
}
handleClose();
} catch (err) {
} catch {
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
} finally {
setIsSubmitting(false);
@ -116,10 +122,14 @@ const TagModal: React.FC<TagModalProps> = ({
if (formData.id && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(t('success.tagDeleted', 'Tag deleted successfully!'));
showSuccessToast(
t('success.tagDeleted', 'Tag deleted successfully!')
);
handleClose();
} catch (err) {
showErrorToast(t('errors.failedToDeleteTag', 'Failed to delete tag.'));
} catch {
showErrorToast(
t('errors.failedToDeleteTag', 'Failed to delete tag.')
);
}
}
};
@ -154,7 +164,10 @@ const TagModal: React.FC<TagModalProps> = ({
onChange={handleChange}
required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.tagNamePlaceholder', 'Enter tag name')}
placeholder={t(
'forms.tagNamePlaceholder',
'Enter tag name'
)}
/>
</div>
</fieldset>
@ -165,7 +178,7 @@ const TagModal: React.FC<TagModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{(tag && tag.id && onDelete) && (
{tag && tag.id && onDelete && (
<button
type="button"
onClick={handleDeleteTag}
@ -190,7 +203,9 @@ const TagModal: React.FC<TagModalProps> = ({
onClick={handleSubmit}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isSubmitting

View file

@ -1,33 +1,41 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { TrashIcon, TagIcon, MagnifyingGlassIcon, CheckIcon, BookOpenIcon, FolderIcon, PencilSquareIcon } from '@heroicons/react/24/solid';
import {
TrashIcon,
TagIcon,
MagnifyingGlassIcon,
CheckIcon,
BookOpenIcon,
FolderIcon,
PencilSquareIcon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import TagModal from './Tag/TagModal';
import { Tag } from '../entities/Tag';
import { deleteTag as apiDeleteTag, createTag, updateTag } from '../utils/tagsService';
import {
deleteTag as apiDeleteTag,
createTag,
updateTag,
} from '../utils/tagsService';
import { useStore } from '../store/useStore';
const Tags: React.FC = () => {
const {
tagsStore: {
tags,
setTags,
isLoading,
setLoading,
isError,
setError
}
tagsStore: { tags, setTags, isLoading, isError },
} = useStore();
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [hoveredTagId, setHoveredTagId] = useState<number | null>(null);
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [tagMetrics, setTagMetrics] = useState<Record<string, {tasks: number, notes: number, projects: number}>>({});
const [tagMetrics, setTagMetrics] = useState<
Record<string, { tasks: number; notes: number; projects: number }>
>({});
const [metricsLoaded, setMetricsLoaded] = useState<boolean>(false);
const [cachedProjects, setCachedProjects] = useState<any[]>([]);
const [, setCachedProjects] = useState<any[]>([]);
useEffect(() => {
const loadMetrics = async () => {
@ -38,10 +46,11 @@ const Tags: React.FC = () => {
try {
// Load all data at once for better performance
const [projectsResponse, tasksResponse, notesResponse] = await Promise.all([
const [projectsResponse, tasksResponse, notesResponse] =
await Promise.all([
fetch('/api/projects'),
fetch('/api/tasks'),
fetch('/api/notes')
fetch('/api/notes'),
]);
let allProjects: any[] = [];
@ -65,31 +74,46 @@ const Tags: React.FC = () => {
}
// Calculate metrics for all tags at once
const metricsMap: Record<string, {tasks: number, notes: number, projects: number}> = {};
const metricsMap: Record<
string,
{ tasks: number; notes: number; projects: number }
> = {};
tags.forEach(tag => {
const tasksCount = allTasks.filter((task: any) =>
task.tags && task.tags.some((taskTag: any) => taskTag.name === tag.name)
tags.forEach((tag) => {
const tasksCount = allTasks.filter(
(task: any) =>
task.tags &&
task.tags.some(
(taskTag: any) => taskTag.name === tag.name
)
).length;
const notesCount = allNotes.filter((note: any) =>
note.tags && note.tags.some((noteTag: any) => noteTag.name === tag.name)
const notesCount = allNotes.filter(
(note: any) =>
note.tags &&
note.tags.some(
(noteTag: any) => noteTag.name === tag.name
)
).length;
const projectsCount = allProjects.filter((project: any) =>
project.tags && project.tags.some((projectTag: any) => projectTag.name === tag.name)
const projectsCount = allProjects.filter(
(project: any) =>
project.tags &&
project.tags.some(
(projectTag: any) =>
projectTag.name === tag.name
)
).length;
metricsMap[tag.name] = {
tasks: tasksCount,
notes: notesCount,
projects: projectsCount
projects: projectsCount,
};
});
setTagMetrics(metricsMap);
setMetricsLoaded(true);
} catch (error) {
console.error('Failed to fetch metrics:', error);
}
@ -98,7 +122,6 @@ const Tags: React.FC = () => {
loadMetrics();
}, [tags]); // Only run when tags change
const handleDeleteTag = async () => {
if (!tagToDelete) return;
try {
@ -117,7 +140,6 @@ const Tags: React.FC = () => {
}
};
const handleEditTag = (tag: Tag) => {
setSelectedTag(tag);
setIsTagModalOpen(true);
@ -127,7 +149,9 @@ const Tags: React.FC = () => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
setTags(tags.map(tag => tag.id === tagData.id ? tagData : tag));
setTags(
tags.map((tag) => (tag.id === tagData.id ? tagData : tag))
);
} else {
const newTag = await createTag(tagData);
setTags([...tags, newTag]);
@ -154,19 +178,24 @@ const Tags: React.FC = () => {
);
// Group tags alphabetically by first letter
const groupedTags = filteredTags.reduce((groups, tag) => {
const groupedTags = filteredTags.reduce(
(groups, tag) => {
const firstLetter = tag.name.charAt(0).toUpperCase();
if (!groups[firstLetter]) {
groups[firstLetter] = [];
}
groups[firstLetter].push(tag);
return groups;
}, {} as Record<string, typeof tags>);
},
{} as Record<string, typeof tags>
);
// Sort the groups by letter and sort tags within each group
const sortedGroupKeys = Object.keys(groupedTags).sort();
sortedGroupKeys.forEach(letter => {
groupedTags[letter].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
sortedGroupKeys.forEach((letter) => {
groupedTags[letter].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
});
if (isLoading) {
@ -190,7 +219,9 @@ const Tags: React.FC = () => {
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<TagIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Tags</h2>
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
Tags
</h2>
</div>
</div>
@ -210,7 +241,9 @@ const Tags: React.FC = () => {
{/* Tags List */}
{filteredTags.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">No tags found.</p>
<p className="text-gray-700 dark:text-gray-300">
No tags found.
</p>
) : (
<div className="space-y-8">
{sortedGroupKeys.map((letter) => (
@ -226,15 +259,30 @@ const Tags: React.FC = () => {
{/* Tags in this group */}
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groupedTags[letter].map((tag) => {
const metrics = tagMetrics[tag.name] || { tasks: 0, notes: 0, projects: 0 };
const hasItems = metrics.tasks > 0 || metrics.notes > 0 || metrics.projects > 0;
const metrics = tagMetrics[
tag.name
] || {
tasks: 0,
notes: 0,
projects: 0,
};
const hasItems =
metrics.tasks > 0 ||
metrics.notes > 0 ||
metrics.projects > 0;
return (
<li
key={tag.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
onMouseEnter={() => setHoveredTagId(tag.id || null)}
onMouseLeave={() => setHoveredTagId(null)}
onMouseEnter={() =>
setHoveredTagId(
tag.id || null
)
}
onMouseLeave={() =>
setHoveredTagId(null)
}
>
<div className="flex items-center justify-between">
{/* Tag Name and Metrics - inline */}
@ -252,24 +300,40 @@ const Tags: React.FC = () => {
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div>
</div>
)}
{metricsLoaded && hasItems && (
{metricsLoaded &&
hasItems && (
<div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400">
{metrics.projects > 0 && (
{metrics.projects >
0 && (
<div className="flex items-center space-x-1">
<FolderIcon className="h-4 w-4 text-purple-500" />
<span>{metrics.projects}</span>
<span>
{
metrics.projects
}
</span>
</div>
)}
{metrics.tasks > 0 && (
{metrics.tasks >
0 && (
<div className="flex items-center space-x-1">
<CheckIcon className="h-4 w-4 text-blue-500" />
<span>{metrics.tasks}</span>
<span>
{
metrics.tasks
}
</span>
</div>
)}
{metrics.notes > 0 && (
{metrics.notes >
0 && (
<div className="flex items-center space-x-1">
<BookOpenIcon className="h-4 w-4 text-green-500" />
<span>{metrics.notes}</span>
<span>
{
metrics.notes
}
</span>
</div>
)}
</div>
@ -279,7 +343,11 @@ const Tags: React.FC = () => {
{/* Action buttons */}
<div className="flex space-x-2 ml-2">
<button
onClick={() => handleEditTag(tag)}
onClick={() =>
handleEditTag(
tag
)
}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Edit ${tag.name}`}
title={`Edit ${tag.name}`}
@ -287,7 +355,11 @@ const Tags: React.FC = () => {
<PencilSquareIcon className="h-4 w-4" />
</button>
<button
onClick={() => openConfirmDialog(tag)}
onClick={() =>
openConfirmDialog(
tag
)
}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Delete ${tag.name}`}
title={`Delete ${tag.name}`}
@ -320,7 +392,9 @@ const Tags: React.FC = () => {
setTags(tags.filter((tag) => tag.id !== tagId));
setTagMetrics((prev) => {
const newMetrics = { ...prev };
const deletedTag = tags.find(t => t.id === tagId);
const deletedTag = tags.find(
(t) => t.id === tagId
);
if (deletedTag) {
delete newMetrics[deletedTag.name];
}

View file

@ -11,8 +11,9 @@ interface NewTaskProps {
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const [taskName, setTaskName] = useState<string>('');
const [showNameLengthHelper, setShowNameLengthHelper] = useState(false);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
const { showSuccessToast, showErrorToast } = useToast();
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
useState(true);
const { showErrorToast } = useToast();
const { t } = useTranslation();
// Fetch task intelligence setting when component mounts
@ -22,7 +23,10 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled);
} catch (error) {
console.error('Error fetching task intelligence setting:', error);
console.error(
'Error fetching task intelligence setting:',
error
);
setTaskIntelligenceEnabled(true); // Default to enabled
}
};
@ -37,11 +41,15 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
// Show helper message for task name if it's too short (only if intelligence is enabled)
if (taskIntelligenceEnabled) {
const trimmedValue = value.trim();
setShowNameLengthHelper(trimmedValue.length > 0 && trimmedValue.length < 10);
setShowNameLengthHelper(
trimmedValue.length > 0 && trimmedValue.length < 10
);
}
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === 'Enter' && taskName.trim()) {
const taskText = taskName.trim();
setTaskName('');
@ -53,7 +61,9 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
} catch (error) {
console.error('Error creating task:', error);
setTaskName(taskText);
showErrorToast(t('errors.taskCreate', 'Failed to create task.'));
showErrorToast(
t('errors.taskCreate', 'Failed to create task.')
);
}
}
};
@ -70,23 +80,42 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
onChange={handleInputChange}
onKeyDown={handleKeyDown}
className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
placeholder={t('tasks.addNewTask', 'Προσθήκη Νέας Εργασίας')}
placeholder={t(
'tasks.addNewTask',
'Προσθήκη Νέας Εργασίας'
)}
/>
</div>
{showNameLengthHelper && taskIntelligenceEnabled && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-4 w-4 text-blue-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
<svg
className="h-4 w-4 text-blue-400 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-2">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>{t('task.nameHelper.title', 'Make it more descriptive!')}</strong>
<strong>
{t(
'task.nameHelper.title',
'Make it more descriptive!'
)}
</strong>
</p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
{t('task.nameHelper.suggestion', 'Try adding more details like "Call dentist to schedule cleaning appointment" instead of just "Call dentist"')}
{t(
'task.nameHelper.suggestion',
'Try adding more details like "Call dentist to schedule cleaning appointment" instead of just "Call dentist"'
)}
</p>
</div>
</div>

View file

@ -26,7 +26,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
metrics,
projects,
onTaskUpdate,
onClose
onClose,
}) => {
const { t } = useTranslation();
const { showSuccessToast } = useToast();
@ -39,7 +39,6 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
return null;
}
// Helper function to check if task is not started
const isNotStarted = (task: Task) => {
return task.status === 'not_started' || task.status === 0;
@ -49,15 +48,17 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
// 1. Today plan tasks (user's intentional selection for today)
// 2. Due today tasks (time-based urgency)
// 3. Suggested tasks from today page (algorithm recommendations)
const todayPlanAvailable = (metrics.today_plan_tasks || []).filter(isNotStarted);
const todayPlanAvailable = (metrics.today_plan_tasks || []).filter(
isNotStarted
);
const dueTodayAvailable = metrics.tasks_due_today.filter(isNotStarted);
const suggestedAvailable = metrics.suggested_tasks.filter(isNotStarted);
// Combine all available tasks with priority (intelligent selection)
const allAvailableTasks = [
...todayPlanAvailable.map(task => ({ task, source: 'today_plan' })),
...dueTodayAvailable.map(task => ({ task, source: 'due_today' })),
...suggestedAvailable.map(task => ({ task, source: 'suggested' }))
...todayPlanAvailable.map((task) => ({ task, source: 'today_plan' })),
...dueTodayAvailable.map((task) => ({ task, source: 'due_today' })),
...suggestedAvailable.map((task) => ({ task, source: 'suggested' })),
];
if (allAvailableTasks.length === 0) {
@ -65,7 +66,8 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
}
// Get current task based on index, wrap around if needed
const currentTaskData = allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
const currentTaskData =
allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
const suggestedTask = currentTaskData.task;
const suggestionSource = currentTaskData.source;
@ -75,7 +77,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
return task.Project.name;
}
if (task.project_id) {
const project = projects.find(p => p.id === task.project_id);
const project = projects.find((p) => p.id === task.project_id);
return project?.name;
}
return null;
@ -90,10 +92,12 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
const updatedTask = {
...suggestedTask,
status: 'in_progress' as const,
today: true
today: true,
};
await onTaskUpdate(updatedTask);
showSuccessToast(t('task.startedSuccessfully', 'Task started successfully!'));
showSuccessToast(
t('task.startedSuccessfully', 'Task started successfully!')
);
} catch (error) {
console.error('Error starting task:', error);
} finally {
@ -102,7 +106,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
};
const handleGiveMeSomethingElse = () => {
setCurrentTaskIndex(prev => prev + 1);
setCurrentTaskIndex((prev) => prev + 1);
};
return (
@ -121,9 +125,21 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
<SparklesIcon className="h-6 w-6 text-purple-500 dark:text-purple-400 mr-3 flex-shrink-0 mt-0.5" />
<div className="flex-1 pr-8">
<p className="text-gray-700 dark:text-gray-300 font-medium mb-2 break-words">
{suggestionSource === 'today_plan' && t('nextTask.suggestionTodayPlan', 'Since there is nothing in progress, what about starting with this task from your today plan')}
{suggestionSource === 'due_today' && t('nextTask.suggestionDueToday', 'Since there is nothing in progress, what about starting with this task due today')}
{suggestionSource === 'suggested' && t('nextTask.suggestionSuggested', 'Since there is nothing in progress, what about starting with this suggested task')}
{suggestionSource === 'today_plan' &&
t(
'nextTask.suggestionTodayPlan',
'Since there is nothing in progress, what about starting with this task from your today plan'
)}
{suggestionSource === 'due_today' &&
t(
'nextTask.suggestionDueToday',
'Since there is nothing in progress, what about starting with this task due today'
)}
{suggestionSource === 'suggested' &&
t(
'nextTask.suggestionSuggested',
'Since there is nothing in progress, what about starting with this suggested task'
)}
</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-3 mb-3">
<p className="text-gray-900 dark:text-gray-100 font-medium break-words">
@ -137,7 +153,10 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
)}
{suggestedTask.due_date && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('forms.task.labels.dueDate', 'Due')}: {new Date(suggestedTask.due_date).toLocaleDateString()}
{t('forms.task.labels.dueDate', 'Due')}:{' '}
{new Date(
suggestedTask.due_date
).toLocaleDateString()}
</p>
)}
</div>
@ -150,8 +169,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
<PlayIcon className="h-4 w-4 mr-2" />
{isUpdating
? t('nextTask.starting', 'Starting...')
: t('nextTask.letsDoIt', "Yes, let's do it!")
}
: t('nextTask.letsDoIt', "Yes, let's do it!")}
</button>
{allAvailableTasks.length > 1 && (
<button
@ -160,7 +178,10 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
className="inline-flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-md transition-colors"
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
{t('nextTask.giveMeSomethingElse', 'Give me something else')}
{t(
'nextTask.giveMeSomethingElse',
'Give me something else'
)}
</button>
)}
</div>

View file

@ -34,11 +34,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
disabled = false,
isChildTask = false,
parentTaskLoading = false,
onEditParent,
onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
onParentRecurrenceChange,
}) => {
const { t } = useTranslation();
const [editingParentRecurrence, setEditingParentRecurrence] = useState(false);
const [editingParentRecurrence, setEditingParentRecurrence] =
useState(false);
const weekdays = [
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
@ -63,30 +64,52 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{ value: 'daily', label: t('recurrence.daily', 'Daily') },
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly') },
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly') },
{ value: 'monthly_weekday', label: t('recurrence.monthlyWeekday', 'Monthly on weekday') },
{ value: 'monthly_last_day', label: t('recurrence.monthlyLastDay', 'Monthly on last day') }
{
value: 'monthly_weekday',
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
},
{
value: 'monthly_last_day',
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
},
];
const renderRecurrenceTypeSelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => (
const renderRecurrenceTypeSelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) => (customOnChange || onChange)('recurrence_type', value as RecurrenceType)}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_type',
value as RecurrenceType
)
}
options={recurrenceTypeOptions}
disabled={isDisabled}
/>
</div>
);
const renderIntervalInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => {
const renderIntervalInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
// Determine max value based on recurrence type
const getMaxValue = () => {
if (recurrenceType === 'daily') return 30;
if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year)
if (recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') return 24; // Max 24 months (2 years)
if (
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day'
)
return 24; // Max 24 months (2 years)
return 99;
};
@ -99,26 +122,39 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) => (customOnChange || onChange)('recurrence_interval', value)}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_interval',
value
)
}
min={1}
max={getMaxValue()}
disabled={isDisabled}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{recurrenceType === 'daily' && t('recurrence.days', 'days')}
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')}
{recurrenceType === 'daily' &&
t('recurrence.days', 'days')}
{recurrenceType === 'weekly' &&
t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') &&
t('recurrence.months', 'months')}
</span>
</div>
</div>
);
};
const renderWeekdaySelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => {
const renderWeekdaySelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
const weekdayOptions = [
{ value: '', label: t('recurrence.anyDay', 'Any day') },
...weekdays
...weekdays,
];
return (
@ -127,8 +163,15 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{t('forms.task.labels.weekday', 'On day')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday !== undefined ? recurrenceWeekday : ''}
onChange={(value) => (customOnChange || onChange)('recurrence_weekday', value !== '' ? parseInt(value as string) : null)}
value={
recurrenceWeekday !== undefined ? recurrenceWeekday : ''
}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_weekday',
value !== '' ? parseInt(value as string) : null
)
}
options={weekdayOptions}
disabled={isDisabled}
/>
@ -136,7 +179,10 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
);
};
const renderMonthDayInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => (
const renderMonthDayInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.monthDay', 'Day of month')}
@ -146,8 +192,16 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
min="1"
max="31"
value={recurrenceMonthDay || ''}
onChange={(e) => (customOnChange || onChange)('recurrence_month_day', e.target.value ? parseInt(e.target.value) : null)}
placeholder={t('recurrence.monthDayPlaceholder', 'Leave empty for current day')}
onChange={(e) =>
(customOnChange || onChange)(
'recurrence_month_day',
e.target.value ? parseInt(e.target.value) : null
)
}
placeholder={t(
'recurrence.monthDayPlaceholder',
'Leave empty for current day'
)}
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
disabled={isDisabled}
/>
@ -162,7 +216,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1}
onChange={(value) => onChange('recurrence_week_of_month', parseInt(value as string))}
onChange={(value) =>
onChange(
'recurrence_week_of_month',
parseInt(value as string)
)
}
options={weekOfMonthOptions}
/>
</div>
@ -172,22 +231,41 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday || 1}
onChange={(value) => onChange('recurrence_weekday', parseInt(value as string))}
onChange={(value) =>
onChange(
'recurrence_weekday',
parseInt(value as string)
)
}
options={weekdays}
/>
</div>
</div>
);
const renderEndDateInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => (
const renderEndDateInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceEndDate', 'End date (optional)')}
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) => (customOnChange || onChange)('recurrence_end_date', value || null)}
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_end_date',
value || null
)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={isDisabled}
/>
</div>
@ -198,8 +276,14 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<ToggleSwitch
checked={completionBased}
onChange={(checked) => onChange('completion_based', checked)}
label={t('forms.task.labels.completionBased', 'Repeat after completion')}
description={t('forms.task.completionBasedHelp', 'If checked, the next task will be created based on completion date instead of due date')}
label={t(
'forms.task.labels.completionBased',
'Repeat after completion'
)}
description={t(
'forms.task.completionBasedHelp',
'If checked, the next task will be created based on completion date instead of due date'
)}
/>
</div>
);
@ -228,39 +312,91 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="text-sm text-blue-800 dark:text-blue-200">
<strong>Recurring Task Instance</strong>
<p className="mt-1">
This task was generated from a recurring task. The recurrence settings shown below are inherited from the original task and cannot be edited here.
This task was generated from a recurring task. The
recurrence settings shown below are inherited from
the original task and cannot be edited here.
</p>
{onParentRecurrenceChange && (
<button
type="button"
onClick={() => setEditingParentRecurrence(!editingParentRecurrence)}
onClick={() =>
setEditingParentRecurrence(
!editingParentRecurrence
)
}
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
editingParentRecurrence
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
}`}
>
{editingParentRecurrence ? 'Cancel Edit' : 'Edit Parent Recurrence'}
{editingParentRecurrence
? 'Cancel Edit'
: 'Edit Parent Recurrence'}
</button>
)}
</div>
</div>
<div className={editingParentRecurrence ? '' : 'opacity-60 pointer-events-none'}>
<div
className={
editingParentRecurrence
? ''
: 'opacity-60 pointer-events-none'
}
>
{editingParentRecurrence && (
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div className="text-xs text-yellow-800 dark:text-yellow-200">
You are editing the parent task's recurrence settings. Changes will affect all future instances of this recurring task.
You are editing the parent task&apos;s
recurrence settings. Changes will affect all
future instances of this recurring task.
</div>
</div>
)}
{recurrenceType === 'none' ? renderRecurrenceTypeSelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence) : (
{recurrenceType === 'none' ? (
renderRecurrenceTypeSelect(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)
) : (
<>
{renderRecurrenceTypeSelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)}
{renderIntervalInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)}
{(recurrenceType === 'weekly' || recurrenceType === 'monthly_weekday') && renderWeekdaySelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)}
{recurrenceType === 'monthly' && renderMonthDayInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)}
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()}
{renderEndDateInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)}
{renderRecurrenceTypeSelect(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{renderIntervalInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{(recurrenceType === 'weekly' ||
recurrenceType === 'monthly_weekday') &&
renderWeekdaySelect(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{recurrenceType === 'monthly' &&
renderMonthDayInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderEndDateInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{renderCompletionBasedToggle()}
</>
)}
@ -270,11 +406,7 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
}
if (recurrenceType === 'none') {
return (
<div className="pb-3">
{renderRecurrenceTypeSelect()}
</div>
);
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
}
return (
@ -291,13 +423,19 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) => onChange('recurrence_type', value as RecurrenceType)}
onChange={(value) =>
onChange('recurrence_type', value as RecurrenceType)
}
options={recurrenceTypeOptions}
disabled={disabled}
/>
</div>
{(recurrenceType === 'daily' || recurrenceType === 'weekly' || recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && (
{(recurrenceType === 'daily' ||
recurrenceType === 'weekly' ||
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
@ -306,21 +444,35 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) => onChange('recurrence_interval', value)}
onChange={(value) =>
onChange('recurrence_interval', value)
}
min={1}
max={
recurrenceType === 'daily' ? 30 :
recurrenceType === 'weekly' ? 52 :
(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') ? 24 :
99
recurrenceType === 'daily'
? 30
: recurrenceType === 'weekly'
? 52
: recurrenceType === 'monthly' ||
recurrenceType ===
'monthly_weekday' ||
recurrenceType ===
'monthly_last_day'
? 24
: 99
}
disabled={disabled}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{recurrenceType === 'daily' && t('recurrence.days', 'days')}
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')}
{recurrenceType === 'daily' &&
t('recurrence.days', 'days')}
{recurrenceType === 'weekly' &&
t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') &&
t('recurrence.months', 'months')}
</span>
</div>
</div>
@ -328,12 +480,20 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceEndDate', 'End date (optional)')}
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) => onChange('recurrence_end_date', value || null)}
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
onChange={(value) =>
onChange('recurrence_end_date', value || null)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={disabled}
/>
</div>
@ -344,7 +504,8 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()}
</div>

View file

@ -9,7 +9,12 @@ interface TaskActionsProps {
onCancel: () => void;
}
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => {
const TaskActions: React.FC<TaskActionsProps> = ({
taskId,
onDelete,
onSave,
onCancel,
}) => {
const { t } = useTranslation();
return (

View file

@ -10,7 +10,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
const { t } = useTranslation();
const getDueDateClass = () => {
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return 'border-blue-700 dark:text-white';
if (dueDate === tomorrow) return 'border-blue-700 dark:text-white';
@ -20,12 +22,18 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
const formatDueDate = () => {
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
if (dueDate === tomorrow)
return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
@ -35,7 +43,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
};
return (
<div className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}>
<div
className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}
>
{formatDueDate()}
</div>
);

View file

@ -10,7 +10,7 @@ interface TaskContentSectionProps {
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
taskId,
value,
onChange
onChange,
}) => {
const { t } = useTranslation();
@ -22,7 +22,10 @@ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
value={value}
onChange={onChange}
className="block w-full border-0 focus:outline-none focus:ring-0 p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 resize-none flex-1 min-h-[200px]"
placeholder={t('forms.noteContentPlaceholder', 'Add task description...')}
placeholder={t(
'forms.noteContentPlaceholder',
'Add task description...'
)}
/>
</div>
);

View file

@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PriorityType, StatusType } from '../../../entities/Task';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import StatusDropdown from '../../Shared/StatusDropdown';
import PriorityDropdown from '../../Shared/PriorityDropdown';
import DatePicker from '../../Shared/DatePicker';
@ -8,7 +9,7 @@ import DatePicker from '../../Shared/DatePicker';
interface TaskMetadataSectionProps {
priority: PriorityType;
dueDate: string;
taskId: number | undefined;
taskId?: number;
onStatusChange: (value: StatusType) => void;
onPriorityChange: (value: PriorityType) => void;
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@ -17,10 +18,10 @@ interface TaskMetadataSectionProps {
const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
priority,
dueDate,
taskId,
onStatusChange,
taskId, // eslint-disable-line @typescript-eslint/no-unused-vars
onStatusChange, // eslint-disable-line @typescript-eslint/no-unused-vars
onPriorityChange,
onDueDateChange
onDueDateChange,
}) => {
const { t } = useTranslation();
@ -43,11 +44,14 @@ const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
value={dueDate}
onChange={(value) => {
const event = {
target: { name: 'due_date', value }
target: { name: 'due_date', value },
} as React.ChangeEvent<HTMLInputElement>;
onDueDateChange(event);
}}
placeholder={t('forms.task.dueDatePlaceholder', 'Select due date')}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
</div>
</div>

View file

@ -19,7 +19,7 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
filteredProjects,
onProjectSelection,
onCreateProject,
isCreatingProject
isCreatingProject,
}) => {
const { t } = useTranslation();
@ -27,7 +27,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
<div className="relative">
<input
type="text"
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
placeholder={t(
'forms.task.projectSearchPlaceholder',
'Search or create a project...'
)}
value={newProjectName}
onChange={onProjectSearch}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
@ -47,7 +50,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t('forms.task.noMatchingProjects', 'No matching projects')}
{t(
'forms.task.noMatchingProjects',
'No matching projects'
)}
</div>
)}
{newProjectName && (
@ -59,7 +65,8 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
>
{isCreatingProject
? t('forms.task.creatingProject', 'Creating...')
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`}
: t('forms.task.createProject', '+ Create') +
` "${newProjectName}"`}
</button>
)}
</div>

View file

@ -1,5 +1,5 @@
import React from 'react';
import { RecurrenceType, Task } from '../../../entities/Task';
import { Task } from '../../../entities/Task';
import RecurrenceInput from '../RecurrenceInput';
interface TaskRecurrenceSectionProps {
@ -17,23 +17,53 @@ const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
parentTaskLoading,
onRecurrenceChange,
onEditParent,
onParentRecurrenceChange
onParentRecurrenceChange,
}) => {
return (
<RecurrenceInput
recurrenceType={parentTask ? (parentTask.recurrence_type || 'none') : (formData.recurrence_type || 'none')}
recurrenceInterval={parentTask ? (parentTask.recurrence_interval || 1) : (formData.recurrence_interval || 1)}
recurrenceEndDate={parentTask ? parentTask.recurrence_end_date : formData.recurrence_end_date}
recurrenceWeekday={parentTask ? parentTask.recurrence_weekday : formData.recurrence_weekday}
recurrenceMonthDay={parentTask ? parentTask.recurrence_month_day : formData.recurrence_month_day}
recurrenceWeekOfMonth={parentTask ? parentTask.recurrence_week_of_month : formData.recurrence_week_of_month}
completionBased={parentTask ? (parentTask.completion_based || false) : (formData.completion_based || false)}
recurrenceType={
parentTask
? parentTask.recurrence_type || 'none'
: formData.recurrence_type || 'none'
}
recurrenceInterval={
parentTask
? parentTask.recurrence_interval || 1
: formData.recurrence_interval || 1
}
recurrenceEndDate={
parentTask
? parentTask.recurrence_end_date
: formData.recurrence_end_date
}
recurrenceWeekday={
parentTask
? parentTask.recurrence_weekday
: formData.recurrence_weekday
}
recurrenceMonthDay={
parentTask
? parentTask.recurrence_month_day
: formData.recurrence_month_day
}
recurrenceWeekOfMonth={
parentTask
? parentTask.recurrence_week_of_month
: formData.recurrence_week_of_month
}
completionBased={
parentTask
? parentTask.completion_based || false
: formData.completion_based || false
}
onChange={onRecurrenceChange}
disabled={!!parentTask}
isChildTask={!!parentTask}
parentTaskLoading={parentTaskLoading}
onEditParent={parentTask ? onEditParent : undefined}
onParentRecurrenceChange={parentTask ? onParentRecurrenceChange : undefined}
onParentRecurrenceChange={
parentTask ? onParentRecurrenceChange : undefined
}
/>
);
};

View file

@ -1,5 +1,4 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import TagInput from '../../Tag/TagInput';
interface TaskTagsSectionProps {
@ -11,10 +10,8 @@ interface TaskTagsSectionProps {
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
tags,
onTagsChange,
availableTags
availableTags,
}) => {
const { t } = useTranslation();
return (
<TagInput
onTagsChange={onTagsChange}

View file

@ -21,7 +21,7 @@ const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
value,
onChange,
taskAnalysis,
taskIntelligenceEnabled
taskIntelligenceEnabled,
}) => {
const { t } = useTranslation();
@ -37,49 +37,82 @@ const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-none focus:outline-none focus:border-none focus:ring-0 shadow-sm py-2"
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
/>
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && (
<div className={`mt-2 p-3 rounded-md border ${
{taskAnalysis &&
taskAnalysis.isVague &&
taskIntelligenceEnabled && (
<div
className={`mt-2 p-3 rounded-md border ${
taskAnalysis.severity === 'high'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
: taskAnalysis.severity === 'medium'
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
}`}>
}`}
>
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className={`h-4 w-4 mt-0.5 ${
<svg
className={`h-4 w-4 mt-0.5 ${
taskAnalysis.severity === 'high'
? 'text-red-400'
: taskAnalysis.severity === 'medium'
? 'text-yellow-400'
: 'text-blue-400'
}`} fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-2">
<p className={`text-sm ${
<p
className={`text-sm ${
taskAnalysis.severity === 'high'
? 'text-red-800 dark:text-red-200'
: taskAnalysis.severity === 'medium'
? 'text-yellow-800 dark:text-yellow-200'
: 'text-blue-800 dark:text-blue-200'
}`}>
}`}
>
<strong>
{taskAnalysis.reason === 'short' && t('task.nameHelper.short', 'Make it more descriptive!')}
{taskAnalysis.reason === 'no_verb' && t('task.nameHelper.noVerb', 'Add an action verb!')}
{taskAnalysis.reason === 'vague_pattern' && t('task.nameHelper.vague', 'Be more specific!')}
{taskAnalysis.reason === 'short' &&
t(
'task.nameHelper.short',
'Make it more descriptive!'
)}
{taskAnalysis.reason === 'no_verb' &&
t(
'task.nameHelper.noVerb',
'Add an action verb!'
)}
{taskAnalysis.reason ===
'vague_pattern' &&
t(
'task.nameHelper.vague',
'Be more specific!'
)}
</strong>
</p>
{taskAnalysis.suggestion && (
<p className={`text-xs mt-1 ${
<p
className={`text-xs mt-1 ${
taskAnalysis.severity === 'high'
? 'text-red-700 dark:text-red-300'
: taskAnalysis.severity === 'medium'
: taskAnalysis.severity ===
'medium'
? 'text-yellow-700 dark:text-yellow-300'
: 'text-blue-700 dark:text-blue-300'
}`}>
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)}
}`}
>
{t(
taskAnalysis.suggestion,
taskAnalysis.suggestion
)}
</p>
)}
</div>

View file

@ -1,11 +1,15 @@
import React from "react";
import { CalendarDaysIcon, CalendarIcon, PlayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { TagIcon, FolderIcon } from "@heroicons/react/24/solid";
import { useTranslation } from "react-i18next";
import TaskPriorityIcon from "./TaskPriorityIcon";
import TaskTags from "./TaskTags";
import { Project } from "../../entities/Project";
import { Task, StatusType } from "../../entities/Task";
import React from 'react';
import {
CalendarDaysIcon,
CalendarIcon,
PlayIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import { TagIcon, FolderIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import TaskPriorityIcon from './TaskPriorityIcon';
import { Project } from '../../entities/Project';
import { Task, StatusType } from '../../entities/Task';
interface TaskHeaderProps {
task: Task;
@ -32,12 +36,18 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const formatDueDate = (dueDate: string) => {
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
if (dueDate === tomorrow)
return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
@ -76,14 +86,24 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const handlePlayToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal
if (task.id && (task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && onTaskUpdate) {
if (
task.id &&
(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) &&
onTaskUpdate
) {
try {
const isCurrentlyInProgress = task.status === 'in_progress' || task.status === 1;
const isCurrentlyInProgress =
task.status === 'in_progress' || task.status === 1;
const updatedTask = {
...task,
status: (isCurrentlyInProgress ? 'not_started' : 'in_progress') as StatusType,
status: (isCurrentlyInProgress
? 'not_started'
: 'in_progress') as StatusType,
// Automatically add to today plan when setting to in_progress
today: isCurrentlyInProgress ? task.today : true
today: isCurrentlyInProgress ? task.today : true,
};
await onTaskUpdate(updatedTask);
} catch (error) {
@ -97,7 +117,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{/* Full view (md and larger) */}
<div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between">
<div className="flex items-center space-x-4 mb-2 md:mb-0">
<TaskPriorityIcon priority={task.priority} status={task.status} onToggleCompletion={onToggleCompletion} />
<TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
/>
<div className="flex flex-col">
<div className="flex items-center">
<span className="text-md text-gray-900 dark:text-gray-100">
@ -120,16 +144,25 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{project.name}</span>
</div>
)}
{project && !hideProjectName && task.tags && task.tags.length > 0 && (
{project &&
!hideProjectName &&
task.tags &&
task.tags.length > 0 && (
<span className="mx-2"></span>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
<span>{task.tags.map(tag => tag.name).join(', ')}</span>
<span>
{task.tags
.map((tag) => tag.name)
.join(', ')}
</span>
</div>
)}
{((project && !hideProjectName) || (task.tags && task.tags.length > 0)) && task.due_date && (
{((project && !hideProjectName) ||
(task.tags && task.tags.length > 0)) &&
task.due_date && (
<span className="mx-2"></span>
)}
{task.due_date && (
@ -138,32 +171,49 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{((project && !hideProjectName) || (task.tags && task.tags.length > 0) || task.due_date) && task.recurrence_type && task.recurrence_type !== 'none' && (
{((project && !hideProjectName) ||
(task.tags && task.tags.length > 0) ||
task.due_date) &&
task.recurrence_type &&
task.recurrence_type !== 'none' && (
<span className="mx-2"></span>
)}
{task.recurrence_type && task.recurrence_type !== 'none' && (
{task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>{formatRecurrence(task.recurrence_type)}</span>
<span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
{/* Today Plan Controls */}
{onToggleToday && (
<button
onClick={handleTodayToggle}
className={`items-center justify-center ${
Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6'
Number(task.today_move_count) > 1
? 'px-2 h-6'
: 'w-6 h-6'
} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`}
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')}
title={
task.today
? t(
'tasks.removeFromToday',
'Remove from today plan'
)
: t('tasks.addToToday', 'Add to today plan')
}
>
{task.today ? (
<CalendarDaysIcon className="h-3 w-3" />
@ -179,15 +229,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)}
{/* Play/In Progress Controls */}
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && (
{(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) && (
<button
onClick={handlePlayToggle}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
(task.status === 'in_progress' || task.status === 1)
task.status === 'in_progress' ||
task.status === 1
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`}
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')}
title={
task.status === 'in_progress' ||
task.status === 1
? t(
'tasks.setNotStarted',
'Set to not started'
)
: t(
'tasks.setInProgress',
'Set in progress'
)
}
>
<PlayIcon className="h-3 w-3" />
</button>
@ -200,7 +265,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="flex items-start">
{/* Priority Icon - Centered vertically with entire card */}
<div className="flex items-center justify-center w-5 mt-4 flex-shrink-0">
<TaskPriorityIcon priority={task.priority} status={task.status} onToggleCompletion={onToggleCompletion} />
<TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
/>
</div>
{/* All content - Task name and metadata */}
@ -229,7 +298,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{task.tags && task.tags.length > 0 && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
<span>{task.tags.map(tag => tag.name).join(', ')}</span>
<span>
{task.tags
.map((tag) => tag.name)
.join(', ')}
</span>
</div>
)}
{task.due_date && (
@ -238,10 +311,15 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{task.recurrence_type && task.recurrence_type !== 'none' && (
{task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>{formatRecurrence(task.recurrence_type)}</span>
<span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div>
)}
</div>
@ -250,17 +328,31 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{/* Mobile badges row */}
<div className="flex items-center flex-wrap justify-start space-x-2 mt-2 ml-7">
{/* Play/In Progress Controls - Mobile */}
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && (
{(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) && (
<button
onClick={handlePlayToggle}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
(task.status === 'in_progress' || task.status === 1)
task.status === 'in_progress' ||
task.status === 1
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`}
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')}
title={
task.status === 'in_progress' ||
task.status === 1
? t(
'tasks.setNotStarted',
'Set to not started'
)
: t(
'tasks.setInProgress',
'Set in progress'
)
}
>
<PlayIcon className="h-3 w-3" />
</button>
@ -275,7 +367,14 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`}
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')}
title={
task.today
? t(
'tasks.removeFromToday',
'Remove from today plan'
)
: t('tasks.addToToday', 'Add to today plan')
}
>
{task.today ? (
<CalendarDaysIcon className="h-3 w-3" />

View file

@ -37,7 +37,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
setIsModalOpen(false);
};
const handleDelete = async (taskId: number) => {
const handleDelete = async () => {
if (task.id) {
await onTaskDelete(task.id);
}
@ -49,6 +49,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
const updatedTask = await toggleTaskCompletion(task.id);
await onTaskUpdate(updatedTask);
} catch (error) {
console.error('Error toggling task completion:', error);
}
}
};
@ -71,12 +72,14 @@ const TaskItem: React.FC<TaskItemProps> = ({
setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
// Use the project from the task's included data if available, otherwise find from projectList
const project = task.Project || projectList.find((p) => p.id === task.project_id);
const project =
task.Project || projectList.find((p) => p.id === task.project_id);
// Check if task is in progress to apply pulsing border animation
const isInProgress = task.status === 'in_progress' || task.status === 1;

View file

@ -1,25 +1,33 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { PriorityType, StatusType, Task } from "../../entities/Task";
import TaskActions from "./TaskActions";
import ConfirmDialog from "../Shared/ConfirmDialog";
import CollapsibleSection from "../Shared/CollapsibleSection";
import { useToast } from "../Shared/ToastContext";
import TimelinePanel from "./TimelinePanel";
import { Project } from "../../entities/Project";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { PriorityType, StatusType, Task } from '../../entities/Task';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from '../Shared/ToastContext';
import TimelinePanel from './TimelinePanel';
import { Project } from '../../entities/Project';
import { fetchTags } from '../../utils/tagsService';
import { fetchTaskById } from '../../utils/tasksService';
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
import { analyzeTaskName, TaskAnalysis } from '../../utils/taskIntelligenceService';
import { useTranslation } from "react-i18next";
import { ClockIcon, TagIcon, FolderIcon, Cog6ToothIcon, ArrowPathIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
analyzeTaskName,
TaskAnalysis,
} from '../../utils/taskIntelligenceService';
import { useTranslation } from 'react-i18next';
import {
ClockIcon,
TagIcon,
FolderIcon,
Cog6ToothIcon,
ArrowPathIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
// Import form sections
import TaskTitleSection from "./TaskForm/TaskTitleSection";
import TaskContentSection from "./TaskForm/TaskContentSection";
import TaskTagsSection from "./TaskForm/TaskTagsSection";
import TaskProjectSection from "./TaskForm/TaskProjectSection";
import TaskMetadataSection from "./TaskForm/TaskMetadataSection";
import TaskRecurrenceSection from "./TaskForm/TaskRecurrenceSection";
import TaskTitleSection from './TaskForm/TaskTitleSection';
import TaskContentSection from './TaskForm/TaskContentSection';
import TaskTagsSection from './TaskForm/TaskTagsSection';
import TaskProjectSection from './TaskForm/TaskProjectSection';
import TaskMetadataSection from './TaskForm/TaskMetadataSection';
import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
interface TaskModalProps {
isOpen: boolean;
@ -43,20 +51,27 @@ const TaskModal: React.FC<TaskModalProps> = ({
onEditParentTask,
}) => {
const [formData, setFormData] = useState<Task>(task);
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []);
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects || []);
const [newProjectName, setNewProjectName] = useState<string>("");
const [tags, setTags] = useState<string[]>(
task.tags?.map((tag) => tag.name) || []
);
const [filteredProjects, setFilteredProjects] = useState<Project[]>(
projects || []
);
const [newProjectName, setNewProjectName] = useState<string>('');
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [localAvailableTags, setLocalAvailableTags] = useState<Array<{name: string}>>([]);
const [localAvailableTags, setLocalAvailableTags] = useState<
Array<{ name: string }>
>([]);
const [tagsLoaded, setTagsLoaded] = useState(false);
const [parentTask, setParentTask] = useState<Task | null>(null);
const [parentTaskLoading, setParentTaskLoading] = useState(false);
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
useState(true);
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
// Collapsible section states
@ -64,27 +79,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
tags: false,
project: false,
metadata: false,
recurrence: false
recurrence: false,
});
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
setExpandedSections(prev => {
const toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = {
...prev,
[section]: !prev[section]
[section]: !prev[section],
};
// Auto-scroll to show the expanded section
if (newExpanded[section]) {
setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth'
behavior: 'smooth',
});
}
}, 100); // Small delay to ensure DOM is updated
@ -92,7 +110,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
return newExpanded;
});
}, []);
},
[]
);
useEffect(() => {
setFormData(task);
@ -107,7 +127,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
}
// Safely find the current project, handling the case where projects might be undefined
const currentProject = projects?.find((project) => project.id === task.project_id);
const currentProject = projects?.find(
(project) => project.id === task.project_id
);
setNewProjectName(currentProject ? currentProject.name : '');
// Fetch parent task if this is a child task
@ -115,7 +137,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
if (task.recurring_parent_id && isOpen) {
setParentTaskLoading(true);
try {
const parent = await fetchTaskById(task.recurring_parent_id);
const parent = await fetchTaskById(
task.recurring_parent_id
);
setParentTask(parent);
} catch (error) {
console.error('Error fetching parent task:', error);
@ -139,7 +163,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled);
} catch (error) {
console.error('Error fetching task intelligence setting:', error);
console.error(
'Error fetching task intelligence setting:',
error
);
setTaskIntelligenceEnabled(true); // Default to enabled
}
}
@ -161,7 +188,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
setParentTask({ ...parentTask, [field]: value });
}
// Also update the form data to reflect the change
setFormData(prev => ({ ...prev, [field]: value, update_parent_recurrence: true }));
setFormData((prev) => ({
...prev,
[field]: value,
update_parent_recurrence: true,
}));
};
useEffect(() => {
@ -170,11 +201,13 @@ const TaskModal: React.FC<TaskModalProps> = ({
try {
const fetchedTags = await fetchTags();
// Ensure fetchedTags is always an array
const safeTagsArray = Array.isArray(fetchedTags) ? fetchedTags : [];
const safeTagsArray = Array.isArray(fetchedTags)
? fetchedTags
: [];
setLocalAvailableTags(safeTagsArray);
setTagsLoaded(true);
} catch (error: any) {
console.error("Error fetching tags:", error);
console.error('Error fetching tags:', error);
// Set empty array as fallback
setLocalAvailableTags([]);
setTagsLoaded(true); // Mark as loaded even on error to prevent retry loop
@ -188,7 +221,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
}
}, [isOpen, tagsLoaded]);
const getPriorityString = (priority: PriorityType | number | undefined): PriorityType => {
const getPriorityString = (
priority: PriorityType | number | undefined
): PriorityType => {
if (typeof priority === 'number') {
const priorityNames: PriorityType[] = ['low', 'medium', 'high'];
return priorityNames[priority] || 'medium';
@ -196,9 +231,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
return priority || 'medium';
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
@ -240,7 +276,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
};
const handleCreateProject = async () => {
if (newProjectName.trim() !== "") {
if (newProjectName.trim() !== '') {
setIsCreatingProject(true);
try {
const newProject = await onCreateProject(newProjectName);
@ -251,7 +287,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
showSuccessToast(t('success.projectCreated'));
} catch (error) {
showErrorToast(t('errors.projectCreationFailed'));
console.error("Error creating project:", error);
console.error('Error creating project:', error);
} finally {
setIsCreatingProject(false);
}
@ -262,7 +298,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
const taskLink = (
<span>
{t('task.updated', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')}
{t('task.updated', 'Task')}{' '}
<a
href={`/task/${formData.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{formData.name}
</a>{' '}
{t('task.updatedSuccessfully', 'updated successfully!')}
</span>
);
showSuccessToast(taskLink);
@ -279,7 +322,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
await onDelete(formData.id);
const taskLink = (
<span>
{t('task.deleted', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.deletedSuccessfully', 'deleted successfully!')}
{t('task.deleted', 'Task')}{' '}
<a
href={`/task/${formData.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{formData.name}
</a>{' '}
{t('task.deletedSuccessfully', 'deleted successfully!')}
</span>
);
showSuccessToast(taskLink);
@ -310,22 +360,26 @@ const TaskModal: React.FC<TaskModalProps> = ({
const target = event.target as Element;
// Ignore clicks on dropdown menus rendered via portal
if (target && (
target.closest('.recurrence-dropdown-menu') ||
if (
target &&
(target.closest('.recurrence-dropdown-menu') ||
target.closest('.number-dropdown-menu') ||
target.closest('.date-picker-menu') ||
target.closest('[class*="fixed z-50"]') ||
target.closest('[class*="z-50"]')
)) {
target.closest('[class*="z-50"]'))
) {
return;
}
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
// Disable body scroll when modal is open
document.body.style.overflow = 'hidden';
} else {
@ -333,7 +387,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
document.body.style.overflow = 'unset';
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
// Clean up: re-enable body scroll
document.body.style.overflow = 'unset';
};
@ -341,15 +395,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === 'Escape') {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
@ -359,23 +413,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100"
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="h-full flex items-start justify-center sm:px-4 sm:py-4">
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col lg:flex-row h-full sm:min-h-[600px] sm:max-h-[90vh]">
{/* Main Form Section */}
<div className={`flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 ${
<div
className={`flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 ${
isTimelineExpanded ? 'lg:pr-2' : ''
}`}>
}`}
>
<div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}>
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{
WebkitOverflowScrolling: 'touch',
}}
>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Task Title Section - Always Visible */}
@ -384,13 +445,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
value={formData.name}
onChange={handleChange}
taskAnalysis={taskAnalysis}
taskIntelligenceEnabled={taskIntelligenceEnabled}
taskIntelligenceEnabled={
taskIntelligenceEnabled
}
/>
{/* Content Section - Always Visible */}
<TaskContentSection
taskId={task.id}
value={formData.note || ""}
value={formData.note || ''}
onChange={handleChange}
/>
@ -398,12 +461,24 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.tags && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.tags', 'Tags')}
{t(
'forms.task.labels.tags',
'Tags'
)}
</h3>
<TaskTagsSection
tags={formData.tags?.map((tag) => tag.name) || []}
onTagsChange={handleTagsChange}
availableTags={localAvailableTags}
tags={
formData.tags?.map(
(tag) =>
tag.name
) || []
}
onTagsChange={
handleTagsChange
}
availableTags={
localAvailableTags
}
/>
</div>
)}
@ -411,16 +486,33 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.project && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.project', 'Project')}
{t(
'forms.task.labels.project',
'Project'
)}
</h3>
<TaskProjectSection
newProjectName={newProjectName}
onProjectSearch={handleProjectSearch}
dropdownOpen={dropdownOpen}
filteredProjects={filteredProjects}
onProjectSelection={handleProjectSelection}
onCreateProject={handleCreateProject}
isCreatingProject={isCreatingProject}
newProjectName={
newProjectName
}
onProjectSearch={
handleProjectSearch
}
dropdownOpen={
dropdownOpen
}
filteredProjects={
filteredProjects
}
onProjectSelection={
handleProjectSelection
}
onCreateProject={
handleCreateProject
}
isCreatingProject={
isCreatingProject
}
/>
</div>
)}
@ -428,24 +520,51 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.metadata && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 overflow-visible">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.statusAndOptions', 'Status & Options')}
{t(
'forms.task.statusAndOptions',
'Status & Options'
)}
</h3>
<TaskMetadataSection
priority={getPriorityString(formData.priority)}
dueDate={formData.due_date || ""}
priority={getPriorityString(
formData.priority
)}
dueDate={
formData.due_date ||
''
}
taskId={task.id}
onStatusChange={(value: StatusType) => {
onStatusChange={(
value: StatusType
) => {
// Universal rule: when setting status to in_progress, also add to today
const updatedData = { ...formData, status: value };
if (value === 'in_progress') {
const updatedData =
{
...formData,
status: value,
};
if (
value ===
'in_progress'
) {
updatedData.today = true;
}
setFormData(updatedData);
setFormData(
updatedData
);
}}
onPriorityChange={(value: PriorityType) =>
setFormData({ ...formData, priority: value })
onPriorityChange={(
value: PriorityType
) =>
setFormData({
...formData,
priority:
value,
})
}
onDueDateChange={
handleChange
}
onDueDateChange={handleChange}
/>
</div>
)}
@ -453,15 +572,32 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.recurrence && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.recurrence', 'Recurrence')}
{t(
'forms.task.recurrence',
'Recurrence'
)}
</h3>
<TaskRecurrenceSection
formData={formData}
parentTask={parentTask}
parentTaskLoading={parentTaskLoading}
onRecurrenceChange={handleRecurrenceChange}
onEditParent={parentTask ? handleEditParent : undefined}
onParentRecurrenceChange={parentTask ? handleParentRecurrenceChange : undefined}
parentTask={
parentTask
}
parentTaskLoading={
parentTaskLoading
}
onRecurrenceChange={
handleRecurrenceChange
}
onEditParent={
parentTask
? handleEditParent
: undefined
}
onParentRecurrenceChange={
parentTask
? handleParentRecurrenceChange
: undefined
}
/>
</div>
)}
@ -476,7 +612,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
<TimelinePanel
taskId={task.id}
isExpanded={isTimelineExpanded}
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)}
onToggle={() =>
setIsTimelineExpanded(
!isTimelineExpanded
)
}
/>
</div>
)}
@ -488,29 +628,41 @@ const TaskModal: React.FC<TaskModalProps> = ({
<div className="flex items-center space-x-1">
{/* Tags Toggle */}
<button
onClick={() => toggleSection('tags')}
onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.task.labels.tags', 'Tags')}
title={t(
'forms.task.labels.tags',
'Tags'
)}
>
<TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && (
{formData.tags &&
formData.tags.length >
0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Project Toggle */}
<button
onClick={() => toggleSection('project')}
onClick={() =>
toggleSection('project')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.project
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.task.labels.project', 'Project')}
title={t(
'forms.task.labels.project',
'Project'
)}
>
<FolderIcon className="h-5 w-5" />
{formData.project_id && (
@ -520,32 +672,48 @@ const TaskModal: React.FC<TaskModalProps> = ({
{/* Status & Options Toggle */}
<button
onClick={() => toggleSection('metadata')}
onClick={() =>
toggleSection('metadata')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.metadata
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.task.statusAndOptions', 'Status & Options')}
title={t(
'forms.task.statusAndOptions',
'Status & Options'
)}
>
<Cog6ToothIcon className="h-5 w-5" />
{(formData.due_date || formData.priority !== 'medium' || (formData.status && formData.status !== 'not_started')) && (
{(formData.due_date ||
formData.priority !==
'medium' ||
(formData.status &&
formData.status !==
'not_started')) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Recurrence Toggle */}
<button
onClick={() => toggleSection('recurrence')}
onClick={() =>
toggleSection('recurrence')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.recurrence
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t('forms.task.recurrence', 'Recurrence')}
title={t(
'forms.task.recurrence',
'Recurrence'
)}
>
<ArrowPathIcon className="h-5 w-5" />
{(formData.recurrence_type || formData.recurring_parent_id) && (
{(formData.recurrence_type ||
formData.recurring_parent_id) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
@ -553,13 +721,25 @@ const TaskModal: React.FC<TaskModalProps> = ({
{/* Right side: Timeline Toggle Button */}
<button
onClick={() => setIsTimelineExpanded(!isTimelineExpanded)}
onClick={() =>
setIsTimelineExpanded(
!isTimelineExpanded
)
}
className={`p-2 rounded-full transition-colors ${
isTimelineExpanded
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={isTimelineExpanded ? t('timeline.hideActivityTimeline') : t('timeline.showActivityTimeline')}
title={
isTimelineExpanded
? t(
'timeline.hideActivityTimeline'
)
: t(
'timeline.showActivityTimeline'
)
}
>
<ClockIcon className="h-5 w-5" />
</button>
@ -575,7 +755,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
type="button"
onClick={handleDeleteClick}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
@ -604,7 +787,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
<TimelinePanel
taskId={task.id}
isExpanded={isTimelineExpanded}
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)}
onToggle={() =>
setIsTimelineExpanded(!isTimelineExpanded)
}
/>
</div>
</div>
@ -613,7 +798,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
{showConfirmDialog && (
<ConfirmDialog
title={t('modals.deleteTask.title', 'Delete Task')}
message={t('modals.deleteTask.confirmation', 'Are you sure you want to delete this task? This action cannot be undone.')}
message={t(
'modals.deleteTask.confirmation',
'Are you sure you want to delete this task? This action cannot be undone.'
)}
onConfirm={handleDeleteConfirm}
onCancel={() => setShowConfirmDialog(false)}
/>

View file

@ -1,6 +1,5 @@
import React from 'react';
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
interface TaskPriorityIconProps {
priority: string | number | undefined;
@ -8,8 +7,11 @@ interface TaskPriorityIconProps {
onToggleCompletion?: () => void;
}
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status, onToggleCompletion }) => {
const { t } = useTranslation();
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
priority,
status,
onToggleCompletion,
}) => {
const getIconColor = () => {
if (status === 'done' || status === 2) return 'text-green-500';

View file

@ -7,7 +7,9 @@ interface TaskRecurrenceBadgeProps {
recurrenceType: RecurrenceType;
}
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({ recurrenceType }) => {
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({
recurrenceType,
}) => {
const { t } = useTranslation();
if (!recurrenceType || recurrenceType === 'none') {

View file

@ -1,6 +1,10 @@
import React from 'react';
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import {
MinusIcon,
CheckCircleIcon,
ArchiveBoxIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
import { StatusType } from '../../entities/Task';
interface TaskStatusBadgeProps {
@ -8,41 +12,42 @@ interface TaskStatusBadgeProps {
className?: string;
}
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
const { t } = useTranslation();
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
status,
className,
}) => {
// Convert numeric status to string
const getStatusString = (status: StatusType | number): StatusType => {
if (typeof status === 'number') {
const statusNames: StatusType[] = ['not_started', 'in_progress', 'done', 'archived'];
const statusNames: StatusType[] = [
'not_started',
'in_progress',
'done',
'archived',
];
return statusNames[status] || 'not_started';
}
return status;
};
const statusString = getStatusString(status);
let statusIcon, statusLabel;
let statusIcon;
switch (statusString) {
case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.notStarted', 'Not Started');
break;
case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
statusLabel = t('status.inProgress', 'In Progress');
break;
case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
statusLabel = t('status.done', 'Done');
break;
case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.archived', 'Archived');
break;
default:
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.unknown', 'Unknown');
}
return (

View file

@ -9,7 +9,11 @@ interface TaskTagsProps {
className?: string;
}
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }) => {
const TaskTags: React.FC<TaskTagsProps> = ({
tags = [],
onTagRemove,
className,
}) => {
const navigate = useNavigate();
const handleTagClick = (tag: Tag) => {
@ -34,7 +38,9 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
className="flex items-center"
>
<TagIcon className="hidden md:block h-4 w-4 text-gray-500 dark:text-gray-300 mr-2" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
<span className="text-xs text-gray-700 dark:text-gray-300">
{tag.name}
</span>
</button>
{onTagRemove && (
<button

View file

@ -1,7 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TaskEvent } from '../../entities/TaskEvent';
import { getTaskTimeline, formatDuration, getEventTypeLabel, getStatusLabel, getPriorityLabel } from '../../utils/taskEventService';
import {
getTaskTimeline,
getEventTypeLabel,
getStatusLabel,
getPriorityLabel,
} from '../../utils/taskEventService';
import {
ClockIcon,
CheckCircleIcon,
@ -11,10 +16,9 @@ import {
CalendarIcon,
FolderIcon,
PlayIcon,
PauseIcon,
ArchiveBoxIcon,
SparklesIcon,
AdjustmentsHorizontalIcon
AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/outline';
interface TaskTimelineProps {
@ -53,24 +57,55 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
}, [taskId]);
const getEventIcon = (eventType: string, newValue?: any) => {
const iconClass = "h-3.5 w-3.5";
const iconClass = 'h-3.5 w-3.5';
switch (eventType) {
case 'created':
return <SparklesIcon className={`${iconClass} text-blue-500`} />;
return (
<SparklesIcon className={`${iconClass} text-blue-500`} />
);
case 'status_changed':
if (newValue?.status === 1) return <PlayIcon className={`${iconClass} text-yellow-500`} />;
if (newValue?.status === 2) return <CheckCircleIcon className={`${iconClass} text-green-500`} />;
if (newValue?.status === 3) return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />;
return <AdjustmentsHorizontalIcon className={`${iconClass} text-blue-500`} />;
if (newValue?.status === 1)
return (
<PlayIcon className={`${iconClass} text-yellow-500`} />
);
if (newValue?.status === 2)
return (
<CheckCircleIcon
className={`${iconClass} text-green-500`}
/>
);
if (newValue?.status === 3)
return (
<ArchiveBoxIcon
className={`${iconClass} text-gray-500`}
/>
);
return (
<AdjustmentsHorizontalIcon
className={`${iconClass} text-blue-500`}
/>
);
case 'completed':
return <CheckCircleIcon className={`${iconClass} text-green-500`} />;
return (
<CheckCircleIcon
className={`${iconClass} text-green-500`}
/>
);
case 'priority_changed':
return <ExclamationTriangleIcon className={`${iconClass} text-orange-500`} />;
return (
<ExclamationTriangleIcon
className={`${iconClass} text-orange-500`}
/>
);
case 'due_date_changed':
return <CalendarIcon className={`${iconClass} text-purple-500`} />;
return (
<CalendarIcon className={`${iconClass} text-purple-500`} />
);
case 'project_changed':
return <FolderIcon className={`${iconClass} text-indigo-500`} />;
return (
<FolderIcon className={`${iconClass} text-indigo-500`} />
);
case 'name_changed':
case 'description_changed':
case 'note_changed':
@ -78,42 +113,49 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
case 'tags_changed':
return <TagIcon className={`${iconClass} text-pink-500`} />;
case 'archived':
return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />;
return (
<ArchiveBoxIcon className={`${iconClass} text-gray-500`} />
);
case 'today_changed':
return <CalendarIcon className={`${iconClass} text-blue-600`} />;
return (
<CalendarIcon className={`${iconClass} text-blue-600`} />
);
default:
return <ClockIcon className={`${iconClass} text-gray-400`} />;
}
};
const getEventDescription = (event: TaskEvent) => {
const { event_type, old_value, new_value, field_name } = event;
const { event_type, old_value, new_value } = event;
switch (event_type) {
case 'created':
return t('timeline.events.taskCreated');
case 'status_changed':
case 'completed':
case 'completed': {
const oldStatus = old_value?.status;
const newStatus = new_value?.status;
if (oldStatus !== undefined && newStatus !== undefined) {
return `${t('timeline.events.status')}: ${getStatusLabel(oldStatus)}${getStatusLabel(newStatus)}`;
}
return t('timeline.events.statusChanged');
case 'priority_changed':
}
case 'priority_changed': {
const oldPriority = old_value?.priority;
const newPriority = new_value?.priority;
if (oldPriority !== undefined && newPriority !== undefined) {
return `${t('timeline.events.priority')}: ${getPriorityLabel(oldPriority)}${getPriorityLabel(newPriority)}`;
}
return t('timeline.events.priorityChanged');
case 'due_date_changed':
}
case 'due_date_changed': {
const oldDate = old_value?.due_date;
const newDate = new_value?.due_date;
if (oldDate || newDate) {
return `${t('timeline.events.dueDate')}: ${oldDate || t('timeline.events.none')}${newDate || t('timeline.events.none')}`;
}
return t('timeline.events.dueDateChanged');
}
case 'name_changed':
return t('timeline.events.nameUpdated');
case 'description_changed':
@ -171,7 +213,9 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<SparklesIcon className="h-6 w-6 mb-2" />
<span className="text-sm text-center">Timeline will appear after saving</span>
<span className="text-sm text-center">
Timeline will appear after saving
</span>
</div>
);
}
@ -188,19 +232,31 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
return (
<div className="h-full overflow-y-auto">
<div className="space-y-2">
{events.map((event, index) => (
{events.map((event) => (
<div key={event.id} className="relative">
{/* Event item */}
<div className="flex items-start space-x-3 py-1 relative z-10">
{/* Icon */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${
event.event_type === 'created' ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700' :
event.event_type === 'completed' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700' :
event.event_type === 'status_changed' && event.new_value?.status === 1 ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700' :
event.event_type === 'priority_changed' ? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-700' :
'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
}`}>
{getEventIcon(event.event_type, event.new_value)}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${
event.event_type === 'created'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
: event.event_type === 'completed'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700'
: event.event_type ===
'status_changed' &&
event.new_value?.status === 1
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
: event.event_type ===
'priority_changed'
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-700'
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
}`}
>
{getEventIcon(
event.event_type,
event.new_value
)}
</div>
{/* Content */}
@ -213,16 +269,23 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
</div>
{/* Additional details for certain events */}
{event.event_type === 'tags_changed' && event.new_value && (
{event.event_type === 'tags_changed' &&
event.new_value && (
<div className="mt-1.5 flex flex-wrap gap-1">
{Array.isArray(event.new_value) && event.new_value.map((tag: any, tagIndex: number) => (
{Array.isArray(event.new_value) &&
event.new_value.map(
(
tag: any,
tagIndex: number
) => (
<span
key={tagIndex}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800"
>
{tag.name || tag}
</span>
))}
)
)}
</div>
)}
</div>

View file

@ -1,11 +1,15 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Task } from "../../entities/Task";
import { Project } from "../../entities/Project";
import TaskModal from "./TaskModal";
import { fetchTaskByUuid, updateTask, deleteTask } from "../../utils/tasksService";
import { createProject } from "../../utils/projectsService";
import { useStore } from "../../store/useStore";
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskModal from './TaskModal';
import {
fetchTaskByUuid,
updateTask,
deleteTask,
} from '../../utils/tasksService';
import { createProject } from '../../utils/projectsService';
import { useStore } from '../../store/useStore';
const TaskView: React.FC = () => {
const { uuid } = useParams<{ uuid: string }>();
@ -18,7 +22,7 @@ const TaskView: React.FC = () => {
useEffect(() => {
const fetchTask = async () => {
if (!uuid) {
setError("No task UUID provided");
setError('No task UUID provided');
setLoading(false);
return;
}
@ -26,8 +30,8 @@ const TaskView: React.FC = () => {
try {
const taskData = await fetchTaskByUuid(uuid);
setTask(taskData);
} catch (err) {
setError("An error occurred while fetching the task");
} catch {
setError('An error occurred while fetching the task');
} finally {
setLoading(false);
}
@ -47,7 +51,7 @@ const TaskView: React.FC = () => {
setTask(updated);
}
} catch (error) {
console.error("Error updating task:", error);
console.error('Error updating task:', error);
}
};
@ -56,7 +60,7 @@ const TaskView: React.FC = () => {
await deleteTask(taskId);
navigate('/today'); // Navigate back to today view after deletion
} catch (error) {
console.error("Error deleting task:", error);
console.error('Error deleting task:', error);
throw error;
}
};
@ -65,7 +69,7 @@ const TaskView: React.FC = () => {
try {
return await createProject({ name });
} catch (error) {
console.error("Error creating project:", error);
console.error('Error creating project:', error);
throw error;
}
};
@ -85,10 +89,10 @@ const TaskView: React.FC = () => {
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-center">
<div className="text-xl font-semibold text-red-600 dark:text-red-400 mb-4">
{error || "Task not found"}
{error || 'Task not found'}
</div>
<button
onClick={() => navigate("/")}
onClick={() => navigate('/')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Go Home

View file

@ -1,8 +1,8 @@
import React, { useEffect, useState, useCallback } from "react";
import { format } from "date-fns";
import { el, enUS, es, ja, uk, de } from "date-fns/locale";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import React, { useEffect, useState, useCallback } from 'react';
import { format } from 'date-fns';
import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import i18n from 'i18next';
import {
ClipboardDocumentListIcon,
ArrowPathIcon,
@ -14,20 +14,23 @@ import {
ChevronDownIcon,
ChevronRightIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
import { fetchProjects } from "../../utils/projectsService";
import { Task } from "../../entities/Task";
import { useStore } from "../../store/useStore";
import TaskList from "./TaskList";
import TodayPlan from "./TodayPlan";
import { Metrics } from "../../entities/Metrics";
import ProductivityAssistant from "../Productivity/ProductivityAssistant";
import NextTaskSuggestion from "./NextTaskSuggestion";
import WeeklyCompletionChart from "./WeeklyCompletionChart";
import TodaySettingsDropdown from "./TodaySettingsDropdown";
import { getProductivityAssistantEnabled, getNextTaskSuggestionEnabled } from "../../utils/profileService";
import { toggleTaskToday } from "../../utils/tasksService";
} from '@heroicons/react/24/outline';
import { fetchTasks, updateTask, deleteTask } from '../../utils/tasksService';
import { fetchProjects } from '../../utils/projectsService';
import { Task } from '../../entities/Task';
import { useStore } from '../../store/useStore';
import TaskList from './TaskList';
import TodayPlan from './TodayPlan';
import { Metrics } from '../../entities/Metrics';
import ProductivityAssistant from '../Productivity/ProductivityAssistant';
import NextTaskSuggestion from './NextTaskSuggestion';
import WeeklyCompletionChart from './WeeklyCompletionChart';
import TodaySettingsDropdown from './TodaySettingsDropdown';
import {
getProductivityAssistantEnabled,
getNextTaskSuggestionEnabled,
} from '../../utils/profileService';
import { toggleTaskToday } from '../../utils/tasksService';
const getLocale = (language: string) => {
switch (language) {
@ -58,7 +61,8 @@ const TasksToday: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [dailyQuote, setDailyQuote] = useState<string>('');
const [productivityAssistantEnabled, setProductivityAssistantEnabled] = useState(true);
const [productivityAssistantEnabled, setProductivityAssistantEnabled] =
useState(true);
const [isSettingsDropdownOpen, setIsSettingsDropdownOpen] = useState(false);
const [todaySettings, setTodaySettings] = useState({
showMetrics: false,
@ -69,7 +73,8 @@ const TasksToday: React.FC = () => {
showProgressBar: true, // Always enabled
showDailyQuote: true,
});
const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] = useState(true);
const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] =
useState(true);
const [showNextTaskSuggestion, setShowNextTaskSuggestion] = useState(true);
const [isSuggestedCollapsed, setIsSuggestedCollapsed] = useState(() => {
const stored = localStorage.getItem('suggestedTasksCollapsed');
@ -104,12 +109,15 @@ const TasksToday: React.FC = () => {
difference: 0,
percentage: 0,
todayCount,
averageCount: 0
averageCount: 0,
};
}
// Sum all completed tasks from the weekly data
const totalCompletedTasks = metrics.weekly_completions.reduce((sum, completion) => sum + completion.count, 0);
const totalCompletedTasks = metrics.weekly_completions.reduce(
(sum, completion) => sum + completion.count,
0
);
// Average is total completed tasks divided by 7
const averageCount = totalCompletedTasks / 7;
@ -117,7 +125,9 @@ const TasksToday: React.FC = () => {
// Calculate percentage change vs average
let percentage = 0;
if (averageCount > 0) {
percentage = Math.round(((todayCount - averageCount) / averageCount) * 100);
percentage = Math.round(
((todayCount - averageCount) / averageCount) * 100
);
} else if (todayCount > 0) {
// If average was 0 but today has completions, it's a 100%+ increase
percentage = 100;
@ -129,7 +139,7 @@ const TasksToday: React.FC = () => {
difference: Math.round((todayCount - averageCount) * 10) / 10, // Round to 1 decimal
percentage: Math.abs(percentage),
todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal
averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
};
} else if (todayCount < averageCount) {
return {
@ -137,7 +147,7 @@ const TasksToday: React.FC = () => {
difference: Math.round((averageCount - todayCount) * 10) / 10, // Round to 1 decimal
percentage: Math.abs(percentage),
todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal
averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
};
} else {
return {
@ -145,7 +155,7 @@ const TasksToday: React.FC = () => {
difference: 0,
percentage: 0,
todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal
averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
};
}
};
@ -171,7 +181,6 @@ const TasksToday: React.FC = () => {
localStorage.setItem('completedTasksCollapsed', newState.toString());
};
// Load data once on component mount
useEffect(() => {
isMounted.current = true;
@ -190,7 +199,10 @@ const TasksToday: React.FC = () => {
setProductivityAssistantEnabled(isEnabled);
}
} catch (error) {
console.error("Failed to load productivity assistant setting:", error);
console.error(
'Failed to load productivity assistant setting:',
error
);
}
try {
@ -200,14 +212,19 @@ const TasksToday: React.FC = () => {
setNextTaskSuggestionEnabled(isNextTaskEnabled);
}
} catch (error) {
console.error("Failed to load next task suggestion setting:", error);
console.error(
'Failed to load next task suggestion setting:',
error
);
}
try {
// Load projects first
const projectsData = await fetchProjects();
if (isMounted.current) {
const safeProjectsData = Array.isArray(projectsData) ? projectsData : [];
const safeProjectsData = Array.isArray(projectsData)
? projectsData
: [];
setLocalProjects(safeProjectsData);
store.projectsStore.setProjects(safeProjectsData);
}
@ -221,7 +238,8 @@ const TasksToday: React.FC = () => {
try {
// Load tasks with metrics
const { tasks: fetchedTasks, metrics: fetchedMetrics } = await fetchTasks("?type=today");
const { tasks: fetchedTasks, metrics: fetchedMetrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setLocalTasks(fetchedTasks);
setMetrics(fetchedMetrics);
@ -229,7 +247,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(fetchedTasks);
}
} catch (error) {
console.error("Failed to fetch tasks:", error);
console.error('Failed to fetch tasks:', error);
if (isMounted.current) {
setIsError(true);
}
@ -241,30 +259,46 @@ const TasksToday: React.FC = () => {
// Load daily quote from translations
try {
const response = await fetch(`/locales/${i18n.language}/quotes.json`);
const response = await fetch(
`/locales/${i18n.language}/quotes.json`
);
if (response.ok) {
const data = await response.json();
if (isMounted.current && data.quotes && data.quotes.length > 0) {
if (
isMounted.current &&
data.quotes &&
data.quotes.length > 0
) {
// Get a random quote from the translated quotes
const randomIndex = Math.floor(Math.random() * data.quotes.length);
const randomIndex = Math.floor(
Math.random() * data.quotes.length
);
setDailyQuote(data.quotes[randomIndex]);
}
} else {
// Fallback to English if language file doesn't exist
const fallbackResponse = await fetch('/locales/en/quotes.json');
const fallbackResponse = await fetch(
'/locales/en/quotes.json'
);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (isMounted.current && fallbackData.quotes && fallbackData.quotes.length > 0) {
const randomIndex = Math.floor(Math.random() * fallbackData.quotes.length);
if (
isMounted.current &&
fallbackData.quotes &&
fallbackData.quotes.length > 0
) {
const randomIndex = Math.floor(
Math.random() * fallbackData.quotes.length
);
setDailyQuote(fallbackData.quotes[randomIndex]);
}
}
}
} catch (error) {
console.error("Failed to load daily quote:", error);
console.error('Failed to load daily quote:', error);
// Ultimate fallback
if (isMounted.current) {
setDailyQuote("Focus on progress, not perfection.");
setDailyQuote('Focus on progress, not perfection.');
}
}
@ -281,9 +315,14 @@ const TasksToday: React.FC = () => {
if (userData.today_settings) {
if (typeof userData.today_settings === 'string') {
try {
settings = JSON.parse(userData.today_settings);
settings = JSON.parse(
userData.today_settings
);
} catch (error) {
console.error('Error parsing today_settings:', error);
console.error(
'Error parsing today_settings:',
error
);
settings = null;
}
} else {
@ -309,7 +348,7 @@ const TasksToday: React.FC = () => {
}
}
} catch (error) {
console.error("Failed to load user settings:", error);
console.error('Failed to load user settings:', error);
}
};
@ -322,14 +361,16 @@ const TasksToday: React.FC = () => {
}, []); // Empty dependency array - only run once on mount
// Memoize task handlers to prevent recreating functions on each render
const handleTaskUpdate = useCallback(async (updatedTask: Task): Promise<void> => {
const handleTaskUpdate = useCallback(
async (updatedTask: Task): Promise<void> => {
if (!updatedTask.id || !isMounted.current) return;
setIsLoading(true);
try {
await updateTask(updatedTask.id, updatedTask);
// Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setLocalTasks(updatedTasks);
@ -338,7 +379,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks);
}
} catch (error) {
console.error("Error updating task:", error);
console.error('Error updating task:', error);
if (isMounted.current) {
setIsError(true);
}
@ -347,16 +388,20 @@ const TasksToday: React.FC = () => {
setIsLoading(false);
}
}
}, [store.tasksStore]);
},
[store.tasksStore]
);
const handleTaskDelete = useCallback(async (taskId: number): Promise<void> => {
const handleTaskDelete = useCallback(
async (taskId: number): Promise<void> => {
if (!isMounted.current) return;
setIsLoading(true);
try {
await deleteTask(taskId);
// Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setLocalTasks(updatedTasks);
@ -365,7 +410,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks);
}
} catch (error) {
console.error("Error deleting task:", error);
console.error('Error deleting task:', error);
if (isMounted.current) {
setIsError(true);
}
@ -374,15 +419,19 @@ const TasksToday: React.FC = () => {
setIsLoading(false);
}
}
}, [store.tasksStore]);
},
[store.tasksStore]
);
const handleToggleToday = useCallback(async (taskId: number): Promise<void> => {
const handleToggleToday = useCallback(
async (taskId: number): Promise<void> => {
if (!isMounted.current) return;
try {
await toggleTaskToday(taskId);
// Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setLocalTasks(updatedTasks);
@ -391,14 +440,14 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks);
}
} catch (error) {
console.error("Error toggling task today status:", error);
console.error('Error toggling task today status:', error);
if (isMounted.current) {
setIsError(true);
}
}
}, [store.tasksStore]);
},
[store.tasksStore]
);
// Calculate today's progress for the progress bar
const getTodayProgress = () => {
@ -409,7 +458,10 @@ const TasksToday: React.FC = () => {
return {
completed: completedToday,
total: totalTodayTasks,
percentage: totalTodayTasks === 0 ? 0 : Math.round((completedToday / totalTodayTasks) * 100)
percentage:
totalTodayTasks === 0
? 0
: Math.round((completedToday / totalTodayTasks) * 100),
};
};
@ -424,7 +476,9 @@ const TasksToday: React.FC = () => {
if (isLoading && localTasks.length === 0) {
return (
<div className="flex justify-center items-center h-64">
<p className="text-gray-500 dark:text-gray-400">{t('common.loading', 'Loading...')}</p>
<p className="text-gray-500 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div>
);
}
@ -433,7 +487,9 @@ const TasksToday: React.FC = () => {
if (isError && localTasks.length === 0) {
return (
<div className="flex justify-center items-center h-64">
<p className="text-red-500">{t('errors.somethingWentWrong', 'Something went wrong')}</p>
<p className="text-red-500">
{t('errors.somethingWentWrong', 'Something went wrong')}
</p>
</div>
);
}
@ -451,7 +507,9 @@ const TasksToday: React.FC = () => {
{t('tasks.today')}
</h2>
<span className="text-gray-500">
{format(new Date(), "PPP", { locale: getLocale(i18n.language) })}
{format(new Date(), 'PPP', {
locale: getLocale(i18n.language),
})}
</span>
</div>
@ -459,9 +517,16 @@ const TasksToday: React.FC = () => {
<div className="flex items-end space-x-2">
<div className="relative">
<button
onClick={() => setIsSettingsDropdownOpen(!isSettingsDropdownOpen)}
onClick={() =>
setIsSettingsDropdownOpen(
!isSettingsDropdownOpen
)
}
className="flex flex-row items-center p-2 group focus:outline-none rounded-md transition-all duration-200 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
title={t('settings.todayPageSettings', 'Today Page Settings')}
title={t(
'settings.todayPageSettings',
'Today Page Settings'
)}
>
<Cog6ToothIcon className="h-5 w-5" />
<span className="text-xs font-medium transition-all duration-200 max-w-0 overflow-hidden opacity-0 group-hover:max-w-xs group-hover:opacity-100 group-focus:max-w-xs group-focus:opacity-100 group-hover:ml-2 group-focus:ml-2">
@ -471,7 +536,9 @@ const TasksToday: React.FC = () => {
<TodaySettingsDropdown
isOpen={isSettingsDropdownOpen}
onClose={() => setIsSettingsDropdownOpen(false)}
onClose={() =>
setIsSettingsDropdownOpen(false)
}
settings={todaySettings}
onSettingsChange={handleSettingsChange}
/>
@ -480,16 +547,20 @@ const TasksToday: React.FC = () => {
</div>
{/* Today Progress Bar - integrated with header */}
{todaySettings.showProgressBar && todayProgress.total > 0 && (
{todaySettings.showProgressBar &&
todayProgress.total > 0 && (
<div className="mb-1">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1">
<div
className="h-1 rounded-full transition-all duration-500 ease-out bg-gradient-to-r from-blue-400 via-blue-500 to-blue-700"
style={{ width: `${todayProgress.percentage}%` }}
style={{
width: `${todayProgress.percentage}%`,
}}
></div>
</div>
{/* Daily Quote */}
{todaySettings.showDailyQuote && dailyQuote && (
{todaySettings.showDailyQuote &&
dailyQuote && (
<div className="mt-2">
<p className="text-s text-gray-400 dark:text-gray-500 font-light text-left">
{dailyQuote}
@ -506,12 +577,16 @@ const TasksToday: React.FC = () => {
<div className="mb-2 grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Combined Task & Project Metrics */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4">
<h3 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">{t('dashboard.overview')}</h3>
<h3 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
{t('dashboard.overview')}
</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center">
<ClipboardDocumentListIcon className="h-4 w-4 text-blue-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.backlog')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.backlog')}
</p>
</div>
<p className="text-sm font-semibold">
{metrics.total_open_tasks}
@ -521,7 +596,9 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between">
<div className="flex items-center">
<ArrowPathIcon className="h-4 w-4 text-green-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.inProgress')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.inProgress')}
</p>
</div>
<p className="text-sm font-semibold">
{metrics.tasks_in_progress_count}
@ -531,7 +608,9 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between">
<div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-red-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.dueToday')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.dueToday')}
</p>
</div>
<p className="text-sm font-semibold">
{metrics.tasks_due_today.length}
@ -541,29 +620,60 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircleIcon className="h-4 w-4 text-green-600 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.completedToday', 'Completed Today')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t(
'tasks.completedToday',
'Completed Today'
)}
</p>
</div>
<div className="flex items-center space-x-1">
{(() => {
const trend = getCompletionTrend();
const getTooltipText = () => {
if (trend.direction === 'same') {
return t('dashboard.sameAsAverage', 'Same as average');
} else if (trend.direction === 'up') {
return t('dashboard.betterThanAverage', '{{percentage}}% more than average', { percentage: trend.percentage });
if (
trend.direction === 'same'
) {
return t(
'dashboard.sameAsAverage',
'Same as average'
);
} else if (
trend.direction === 'up'
) {
return t(
'dashboard.betterThanAverage',
'{{percentage}}% more than average',
{
percentage:
trend.percentage,
}
);
} else {
return t('dashboard.worseThanAverage', '{{percentage}}% less than average', { percentage: trend.percentage });
return t(
'dashboard.worseThanAverage',
'{{percentage}}% less than average',
{
percentage:
trend.percentage,
}
);
}
};
return (
<>
{(trend.direction === 'up' || trend.direction === 'down') && (
{(trend.direction ===
'up' ||
trend.direction ===
'down') && (
<div className="relative group">
{trend.direction === 'up' && (
{trend.direction ===
'up' && (
<ArrowUpIcon className="h-3 w-3 text-green-500" />
)}
{trend.direction === 'down' && (
{trend.direction ===
'down' && (
<ArrowDownIcon className="h-3 w-3 text-red-500" />
)}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
@ -572,7 +682,11 @@ const TasksToday: React.FC = () => {
</div>
)}
<p className="text-sm font-semibold">
{metrics.tasks_completed_today.length}
{
metrics
.tasks_completed_today
.length
}
</p>
</>
);
@ -583,10 +697,16 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between">
<div className="flex items-center">
<FolderIcon className="h-4 w-4 text-purple-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('projects.active')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.active')}
</p>
</div>
<p className="text-sm font-semibold">
{Array.isArray(localProjects) ? localProjects.filter(project => project.active).length : 0}
{Array.isArray(localProjects)
? localProjects.filter(
(project) => project.active
).length
: 0}
</p>
</div>
</div>
@ -594,14 +714,20 @@ const TasksToday: React.FC = () => {
{/* Weekly Completion Chart */}
<div className="lg:col-span-2">
<WeeklyCompletionChart data={metrics.weekly_completions} />
<WeeklyCompletionChart
data={metrics.weekly_completions}
/>
</div>
</div>
)}
{/* Productivity Assistant - Conditionally Rendered */}
{todaySettings.showProductivity && productivityAssistantEnabled && (
<ProductivityAssistant tasks={localTasks} projects={localProjects} />
{todaySettings.showProductivity &&
productivityAssistantEnabled && (
<ProductivityAssistant
tasks={localTasks}
projects={localProjects}
/>
)}
{/* Today Plan */}
@ -617,13 +743,18 @@ const TasksToday: React.FC = () => {
{todaySettings.showIntelligence && (
<div className="mt-2">
{/* Next Task Suggestion */}
{nextTaskSuggestionEnabled && showNextTaskSuggestion && (
{nextTaskSuggestionEnabled &&
showNextTaskSuggestion && (
<NextTaskSuggestion
metrics={{
tasks_due_today: metrics.tasks_due_today,
suggested_tasks: metrics.suggested_tasks,
tasks_in_progress: metrics.tasks_in_progress,
today_plan_tasks: metrics.today_plan_tasks
tasks_due_today:
metrics.tasks_due_today,
suggested_tasks:
metrics.suggested_tasks,
tasks_in_progress:
metrics.tasks_in_progress,
today_plan_tasks:
metrics.today_plan_tasks,
}}
projects={localProjects}
onTaskUpdate={handleTaskUpdate}
@ -638,9 +769,13 @@ const TasksToday: React.FC = () => {
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
onClick={toggleSuggestedCollapsed}
>
<h3 className="text-xl font-medium">{t('tasks.suggested')}</h3>
<h3 className="text-xl font-medium">
{t('tasks.suggested')}
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{metrics.suggested_tasks.length}</span>
<span className="text-sm text-gray-500 mr-2">
{metrics.suggested_tasks.length}
</span>
{isSuggestedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : (
@ -663,9 +798,12 @@ const TasksToday: React.FC = () => {
)}
{/* Due Today Tasks - Conditionally Rendered */}
{todaySettings.showDueToday && metrics.tasks_due_today.length > 0 && (
{todaySettings.showDueToday &&
metrics.tasks_due_today.length > 0 && (
<div className="mb-6">
<h3 className="text-xl font-medium mt-6 mb-2">{t('tasks.dueToday')}</h3>
<h3 className="text-xl font-medium mt-6 mb-2">
{t('tasks.dueToday')}
</h3>
<TaskList
tasks={metrics.tasks_due_today}
onTaskUpdate={handleTaskUpdate}
@ -677,15 +815,20 @@ const TasksToday: React.FC = () => {
)}
{/* Completed Tasks - Conditionally Rendered */}
{todaySettings.showCompleted && metrics.tasks_completed_today.length > 0 && (
{todaySettings.showCompleted &&
metrics.tasks_completed_today.length > 0 && (
<div className="mb-6">
<div
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
onClick={toggleCompletedCollapsed}
>
<h3 className="text-xl font-medium">{t('tasks.completedToday')}</h3>
<h3 className="text-xl font-medium">
{t('tasks.completedToday')}
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{metrics.tasks_completed_today.length}</span>
<span className="text-sm text-gray-500 mr-2">
{metrics.tasks_completed_today.length}
</span>
{isCompletedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : (
@ -712,7 +855,6 @@ const TasksToday: React.FC = () => {
{t('tasks.noTasksAvailable')}
</p>
)}
</div>
</div>
);

View file

@ -12,17 +12,18 @@ interface TimelinePanelProps {
const TimelinePanel: React.FC<TimelinePanelProps> = ({
taskId,
isExpanded,
onToggle
onToggle,
}) => {
const { t } = useTranslation();
return (
<div className={`${
<div
className={`${
isExpanded
? 'w-full lg:w-80 opacity-100'
: 'w-0 lg:w-12 opacity-0 lg:opacity-100'
} border-t lg:border-t-0 lg:border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col transition-all duration-300 overflow-hidden`}>
} border-t lg:border-t-0 lg:border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col transition-all duration-300 overflow-hidden`}
>
{/* Collapsed state - envelope icon */}
{!isExpanded && (
<div className="hidden lg:flex flex-col items-center justify-center h-full p-2">

View file

@ -1,9 +1,9 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import TaskList from "./TaskList";
import { Task } from "../../entities/Task";
import { Project } from "../../entities/Project";
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CalendarDaysIcon } from '@heroicons/react/24/outline';
import TaskList from './TaskList';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
interface TodayPlanProps {
todayPlanTasks: Task[] | undefined;
@ -31,10 +31,16 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
<div className="text-center py-8">
<CalendarDaysIcon className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-2">
{t('tasks.noPlanToday', 'No tasks planned for today yet')}
{t(
'tasks.noPlanToday',
'No tasks planned for today yet'
)}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{t('tasks.addToPlanHint', 'Use the calendar icons next to suggested tasks to add them to your today plan')}
{t(
'tasks.addToPlanHint',
'Use the calendar icons next to suggested tasks to add them to your today plan'
)}
</p>
</div>
</>

View file

@ -42,7 +42,10 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
onClose();
}
};
@ -59,7 +62,7 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
const handleToggle = (key: keyof typeof localSettings) => {
const newSettings = {
...localSettings,
[key]: !localSettings[key]
[key]: !localSettings[key],
};
setLocalSettings(newSettings);
@ -93,13 +96,45 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
if (!isOpen) return null;
const settingsOptions: Array<{key: keyof typeof localSettings, label: string, icon: React.ElementType, disabled?: boolean}> = [
{ key: 'showDailyQuote', label: t('settings.showDailyQuote', 'Show Daily Quote'), icon: ChatBubbleBottomCenterTextIcon },
{ key: 'showMetrics', label: t('settings.showMetrics', 'Show Metrics'), icon: ChartBarIcon },
{ key: 'showProductivity', label: t('settings.showProductivity', 'Show Productivity Insights'), icon: LightBulbIcon },
{ key: 'showIntelligence', label: t('settings.showIntelligence', 'Show Intelligence Suggestions'), icon: SparklesIcon },
{ key: 'showDueToday', label: t('settings.showDueToday', 'Show Due Today Tasks'), icon: ClockIcon },
{ key: 'showCompleted', label: t('settings.showCompleted', 'Show Completed Tasks'), icon: TrophyIcon },
const settingsOptions: Array<{
key: keyof typeof localSettings;
label: string;
icon: React.ElementType;
disabled?: boolean;
}> = [
{
key: 'showDailyQuote',
label: t('settings.showDailyQuote', 'Show Daily Quote'),
icon: ChatBubbleBottomCenterTextIcon,
},
{
key: 'showMetrics',
label: t('settings.showMetrics', 'Show Metrics'),
icon: ChartBarIcon,
},
{
key: 'showProductivity',
label: t('settings.showProductivity', 'Show Productivity Insights'),
icon: LightBulbIcon,
},
{
key: 'showIntelligence',
label: t(
'settings.showIntelligence',
'Show Intelligence Suggestions'
),
icon: SparklesIcon,
},
{
key: 'showDueToday',
label: t('settings.showDueToday', 'Show Due Today Tasks'),
icon: ClockIcon,
},
{
key: 'showCompleted',
label: t('settings.showCompleted', 'Show Completed Tasks'),
icon: TrophyIcon,
},
];
return (
@ -118,15 +153,22 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
const isDisabled = option.disabled || isSaving;
return (
<div key={option.key} className={`flex items-center justify-between ${isDisabled ? 'opacity-60' : ''}`}>
<div
key={option.key}
className={`flex items-center justify-between ${isDisabled ? 'opacity-60' : ''}`}
>
<div className="flex items-center flex-1">
<IconComponent className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<label className={`text-sm text-gray-700 dark:text-gray-300 ${!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed'} flex-1`}>
<label
className={`text-sm text-gray-700 dark:text-gray-300 ${!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed'} flex-1`}
>
{option.label}
</label>
</div>
<button
onClick={() => !isDisabled && handleToggle(option.key)}
onClick={() =>
!isDisabled && handleToggle(option.key)
}
disabled={isDisabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
localSettings[option.key]
@ -136,7 +178,9 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
localSettings[option.key] ? 'translate-x-5' : 'translate-x-1'
localSettings[option.key]
? 'translate-x-5'
: 'translate-x-1'
}`}
/>
</button>

View file

@ -1,5 +1,12 @@
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from 'recharts';
import {
BarChart,
Bar,
XAxis,
YAxis,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import { useTranslation } from 'react-i18next';
import { WeeklyCompletion } from '../../entities/Metrics';
@ -7,10 +14,12 @@ interface WeeklyCompletionChartProps {
data: WeeklyCompletion[];
}
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) => {
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({
data,
}) => {
const { t } = useTranslation();
const CustomTooltip = ({ active, payload, label }: any) => {
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
@ -19,7 +28,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
{data.dayName}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{data.count} {data.count === 1 ? t('tasks.taskCompleted') : t('tasks.tasksCompleted')}
{data.count}{' '}
{data.count === 1
? t('tasks.taskCompleted')
: t('tasks.tasksCompleted')}
</p>
</div>
);
@ -34,7 +46,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
</h3>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<BarChart
data={data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<XAxis
dataKey="dayName"
axisLine={false}
@ -42,7 +57,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400'
className: 'text-gray-600 dark:text-gray-400',
}}
/>
<YAxis
@ -51,7 +66,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400'
className: 'text-gray-600 dark:text-gray-400',
}}
allowDecimals={false}
width={25}

View file

@ -1,4 +1,4 @@
import { Project } from "../../entities/Project";
import { Project } from '../../entities/Project';
export const getDescription = (
query: URLSearchParams,
@ -8,30 +8,36 @@ export const getDescription = (
try {
// Default descriptions as fallbacks in case translation function fails
const defaultDescriptions = {
project: "Project tasks",
today: "Tasks due today or scheduled for immediate attention",
inbox: "Uncategorized tasks without project or due date",
next: "Tasks that are actionable in the near future",
upcoming: "Tasks scheduled for the upcoming week",
someday: "Tasks without urgency or specific due date",
project: 'Project tasks',
today: 'Tasks due today or scheduled for immediate attention',
inbox: 'Uncategorized tasks without project or due date',
next: 'Tasks that are actionable in the near future',
upcoming: 'Tasks scheduled for the upcoming week',
someday: 'Tasks without urgency or specific due date',
completed: "Tasks you've completed",
allTasks: "All tasks from different projects and priorities"
allTasks: 'All tasks from different projects and priorities',
};
// Check for project_id first
const projectId = query.get('project_id');
if (projectId) {
try {
const project = projects.find((p) => p.id?.toString() === projectId);
const project = projects.find(
(p) => p.id?.toString() === projectId
);
if (project) {
return t("taskViews.project.withName", { projectName: project.name });
return t('taskViews.project.withName', {
projectName: project.name,
});
} else {
return t("taskViews.project.noName");
return t('taskViews.project.noName');
}
} catch (e) {
console.error("Translation error for project description:", e);
console.error('Translation error for project description:', e);
// Fallback with project name if available
const project = projects.find((p) => p.id?.toString() === projectId);
const project = projects.find(
(p) => p.id?.toString() === projectId
);
return project
? `Tasks for project: ${project.name}`
: defaultDescriptions.project;
@ -41,38 +47,41 @@ export const getDescription = (
// Then check for type and status parameters
try {
if (query.get('type') === 'today') {
return t("taskViews.today");
return t('taskViews.today');
}
if (query.get('type') === 'inbox') {
return t("taskViews.inbox");
return t('taskViews.inbox');
}
if (query.get('type') === 'next') {
return t("taskViews.next");
return t('taskViews.next');
}
if (query.get('type') === 'upcoming') {
return t("taskViews.upcoming");
return t('taskViews.upcoming');
}
if (query.get('type') === 'someday') {
return t("taskViews.someday");
return t('taskViews.someday');
}
if (query.get('status') === 'done') {
return t("taskViews.completed");
return t('taskViews.completed');
}
return t("taskViews.allTasks");
return t('taskViews.allTasks');
} catch (e) {
console.error("Translation error for task view description:", e);
console.error('Translation error for task view description:', e);
// Return appropriate fallback based on type or status
if (query.get('type') === 'today') return defaultDescriptions.today;
if (query.get('type') === 'inbox') return defaultDescriptions.inbox;
if (query.get('type') === 'next') return defaultDescriptions.next;
if (query.get('type') === 'upcoming') return defaultDescriptions.upcoming;
if (query.get('type') === 'someday') return defaultDescriptions.someday;
if (query.get('status') === 'done') return defaultDescriptions.completed;
if (query.get('type') === 'upcoming')
return defaultDescriptions.upcoming;
if (query.get('type') === 'someday')
return defaultDescriptions.someday;
if (query.get('status') === 'done')
return defaultDescriptions.completed;
return defaultDescriptions.allTasks;
}
} catch (error) {
console.error("Error in getDescription:", error);
return "Tasks overview";
console.error('Error in getDescription:', error);
return 'Tasks overview';
}
};

View file

@ -1,4 +1,4 @@
import { Project } from "../../entities/Project";
import { Project } from '../../entities/Project';
import {
FolderIcon,
CalendarIcon,
@ -25,12 +25,17 @@ export const getTitleAndIcon = (
upcoming: 'Upcoming',
someday: 'Someday',
completed: 'Completed',
allTasks: 'All Tasks'
allTasks: 'All Tasks',
};
const projectId = query.get('project_id');
if (projectId) {
const project = projects.find((p) => p.id?.toString() === projectId);
return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon };
const project = projects.find(
(p) => p.id?.toString() === projectId
);
return {
title: project ? project.name : t('sidebar.projects'),
icon: FolderIcon,
};
}
try {
@ -41,32 +46,47 @@ export const getTitleAndIcon = (
return { title: t('sidebar.inbox'), icon: InboxIcon };
}
if (query.get('type') === 'next') {
return { title: t('sidebar.nextActions'), icon: ArrowRightIcon };
return {
title: t('sidebar.nextActions'),
icon: ArrowRightIcon,
};
}
if (query.get('type') === 'upcoming') {
return { title: t('sidebar.upcoming'), icon: ClockIcon };
}
if (query.get('type') === 'someday') {
return { title: t('taskViews.someday') || defaultTitles.someday, icon: MoonIcon };
return {
title: t('taskViews.someday') || defaultTitles.someday,
icon: MoonIcon,
};
}
if (query.get('status') === 'done') {
return { title: t('sidebar.completed'), icon: CheckCircleIcon };
}
return { title: t('sidebar.allTasks'), icon: Bars4Icon };
} catch (e) {
console.error("Translation error for task view title:", e);
console.error('Translation error for task view title:', e);
// Return appropriate fallback based on type or status
if (query.get('type') === 'today') return { title: defaultTitles.today, icon: CalendarIcon };
if (query.get('type') === 'inbox') return { title: defaultTitles.inbox, icon: InboxIcon };
if (query.get('type') === 'next') return { title: defaultTitles.next, icon: ArrowRightIcon };
if (query.get('type') === 'upcoming') return { title: defaultTitles.upcoming, icon: ClockIcon };
if (query.get('type') === 'someday') return { title: defaultTitles.someday, icon: MoonIcon };
if (query.get('status') === 'done') return { title: defaultTitles.completed, icon: CheckCircleIcon };
if (query.get('type') === 'today')
return { title: defaultTitles.today, icon: CalendarIcon };
if (query.get('type') === 'inbox')
return { title: defaultTitles.inbox, icon: InboxIcon };
if (query.get('type') === 'next')
return { title: defaultTitles.next, icon: ArrowRightIcon };
if (query.get('type') === 'upcoming')
return { title: defaultTitles.upcoming, icon: ClockIcon };
if (query.get('type') === 'someday')
return { title: defaultTitles.someday, icon: MoonIcon };
if (query.get('status') === 'done')
return {
title: defaultTitles.completed,
icon: CheckCircleIcon,
};
return { title: defaultTitles.allTasks, icon: Bars4Icon };
}
} catch (error) {
console.error("Error in getTitleAndIcon:", error);
return { title: "Tasks", icon: Bars4Icon };
console.error('Error in getTitleAndIcon:', error);
return { title: 'Tasks', icon: Bars4Icon };
}
};

View file

@ -1,21 +1,21 @@
import React, { useEffect, useState, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import TaskList from "./Task/TaskList";
import NewTask from "./Task/NewTask";
import { Task } from "../entities/Task";
import { Project } from "../entities/Project";
import { getTitleAndIcon } from "./Task/getTitleAndIcon";
import { getDescription } from "./Task/getDescription";
import { createTask, toggleTaskToday } from "../utils/tasksService";
import { useToast } from "./Shared/ToastContext";
import React, { useEffect, useState, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import TaskList from './Task/TaskList';
import NewTask from './Task/NewTask';
import { Task } from '../entities/Task';
import { Project } from '../entities/Project';
import { getTitleAndIcon } from './Task/getTitleAndIcon';
import { getDescription } from './Task/getDescription';
import { createTask, toggleTaskToday } from '../utils/tasksService';
import { useToast } from './Shared/ToastContext';
import {
TagIcon,
XMarkIcon,
ChevronDownIcon,
ChevronDoubleDownIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/solid";
} from '@heroicons/react/24/solid';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@ -27,7 +27,7 @@ const getSearchPlaceholder = (language: string): string => {
es: 'Buscar tareas...',
de: 'Aufgaben suchen...',
jp: 'タスクを検索...',
ua: 'Пошук завдань...'
ua: 'Пошук завдань...',
};
return placeholders[language] || 'Search tasks...';
@ -41,8 +41,8 @@ const Tasks: React.FC = () => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
const [taskSearchQuery, setTaskSearchQuery] = useState<string>("");
const [orderBy, setOrderBy] = useState<string>('due_date:asc');
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
@ -56,22 +56,25 @@ const Tasks: React.FC = () => {
: getTitleAndIcon(query, projects, t);
const IconComponent =
typeof icon === "string" ? React.createElement(icon) : icon;
typeof icon === 'string' ? React.createElement(icon) : icon;
const tag = query.get("tag");
const status = query.get("status");
const tag = query.get('tag');
const status = query.get('status');
useEffect(() => {
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
const savedOrderBy = localStorage.getItem('order_by') || 'due_date:asc';
setOrderBy(savedOrderBy);
const params = new URLSearchParams(location.search);
if (!params.get("order_by")) {
params.set("order_by", savedOrderBy);
navigate({
if (!params.get('order_by')) {
params.set('order_by', savedOrderBy);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
},
{ replace: true }
);
}
}, [location.pathname]);
@ -85,10 +88,10 @@ const Tasks: React.FC = () => {
}
};
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
@ -97,24 +100,26 @@ const Tasks: React.FC = () => {
setLoading(true);
setError(null);
try {
const tagId = query.get("tag");
const tagId = query.get('tag');
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
fetch("/api/projects"),
fetch(
`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}`
),
fetch('/api/projects'),
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []);
} else {
throw new Error("Failed to fetch tasks.");
throw new Error('Failed to fetch tasks.');
}
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error("Failed to fetch projects.");
throw new Error('Failed to fetch projects.');
}
} catch (error) {
setError((error as Error).message);
@ -128,7 +133,7 @@ const Tasks: React.FC = () => {
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag");
params.delete('tag');
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
@ -144,13 +149,20 @@ const Tasks: React.FC = () => {
// Show success toast with task link
const taskLink = (
<span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error("Error creating task:", error);
setError("Error creating task.");
console.error('Error creating task:', error);
setError('Error creating task.');
throw error; // Re-throw to allow proper error handling
}
};
@ -158,8 +170,8 @@ const Tasks: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
});
@ -171,31 +183,33 @@ const Tasks: React.FC = () => {
);
} else {
const errorData = await response.json();
console.error("Failed to update task:", errorData.error);
setError("Failed to update task.");
console.error('Failed to update task:', errorData.error);
setError('Failed to update task.');
}
} catch (error) {
console.error("Error updating task:", error);
setError("Error updating task.");
console.error('Error updating task:', error);
setError('Error updating task.');
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
method: 'DELETE',
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} else {
const errorData = await response.json();
console.error("Failed to delete task:", errorData.error);
setError("Failed to delete task.");
console.error('Failed to delete task:', errorData.error);
setError('Failed to delete task.');
}
} catch (error) {
console.error("Error deleting task:", error);
setError("Error deleting task.");
console.error('Error deleting task:', error);
setError('Error deleting task.');
}
};
@ -204,10 +218,10 @@ const Tasks: React.FC = () => {
await toggleTaskToday(taskId);
// Refetch data to ensure consistency with all task relationships
const params = new URLSearchParams(location.search);
const type = params.get("type") || "all";
const tag = params.get("tag");
const project = params.get("project");
const priority = params.get("priority");
const type = params.get('type') || 'all';
const tag = params.get('tag');
const project = params.get('project');
const priority = params.get('priority');
let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`;
if (tag) apiPath += `&tag=${tag}`;
@ -215,7 +229,7 @@ const Tasks: React.FC = () => {
if (priority) apiPath += `&priority=${priority}`;
const response = await fetch(apiPath, {
credentials: "include",
credentials: 'include',
});
if (response.ok) {
@ -223,27 +237,30 @@ const Tasks: React.FC = () => {
setTasks(data.tasks || data);
}
} catch (error) {
console.error("Error toggling task today status:", error);
setError("Error toggling task today status.");
console.error('Error toggling task today status:', error);
setError('Error toggling task today status.');
}
};
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem("order_by", order);
localStorage.setItem('order_by', order);
const params = new URLSearchParams(location.search);
params.set("order_by", order);
navigate({
params.set('order_by', order);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
},
{ replace: true }
);
setDropdownOpen(false);
};
const description = getDescription(query, projects, t);
const isNewTaskAllowed = () => {
return status !== "done";
return status !== 'done';
};
const filteredTasks = tasks.filter((task) =>
@ -255,7 +272,9 @@ const Tasks: React.FC = () => {
<div className="w-full max-w-5xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4">
<div className="flex items-center mb-2 sm:mb-0">
{IconComponent && <IconComponent className="h-6 w-6 mr-2" />}
{IconComponent && (
<IconComponent className="h-6 w-6 mr-2" />
)}
<h2 className="text-2xl font-light">{title}</h2>
{tag && (
@ -274,7 +293,10 @@ const Tasks: React.FC = () => {
)}
</div>
<div className="relative inline-block text-left" ref={dropdownRef}>
<div
className="relative inline-block text-left"
ref={dropdownRef}
>
<button
type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
@ -283,8 +305,13 @@ const Tasks: React.FC = () => {
aria-haspopup="true"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{" "}
{t(`sort.${orderBy.split(":")[0]}`, capitalize(orderBy.split(":")[0].replace("_", " ")))}
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{' '}
{t(
`sort.${orderBy.split(':')[0]}`,
capitalize(
orderBy.split(':')[0].replace('_', ' ')
)
)}
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button>
@ -295,21 +322,33 @@ const Tasks: React.FC = () => {
aria-orientation="vertical"
aria-labelledby="menu-button"
>
<div className="py-1 max-h-60 overflow-y-auto" role="none">
<div
className="py-1 max-h-60 overflow-y-auto"
role="none"
>
{[
"due_date:asc",
"name:asc",
"priority:desc",
"status:desc",
"created_at:desc",
'due_date:asc',
'name:asc',
'priority:desc',
'status:desc',
'created_at:desc',
].map((order) => (
<button
key={order}
onClick={() => handleSortChange(order)}
onClick={() =>
handleSortChange(order)
}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
role="menuitem"
>
{t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))}
{t(
`sort.${order.split(':')[0]}`,
capitalize(
order
.split(':')[0]
.replace('_', ' ')
)
)}
</button>
))}
</div>
@ -318,7 +357,6 @@ const Tasks: React.FC = () => {
</div>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
@ -345,7 +383,10 @@ const Tasks: React.FC = () => {
{isNewTaskAllowed() && (
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({ name: taskName, status: "not_started" })
await handleTaskCreate({
name: taskName,
status: 'not_started',
})
}
/>
)}
@ -361,7 +402,10 @@ const Tasks: React.FC = () => {
/>
) : (
<p className="text-gray-500 text-center mt-4">
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}
{t(
'tasks.noTasksAvailable',
'Δεν υπάρχουν διαθέσιμες εργασίες.'
)}
</p>
)}
</>

View file

@ -25,17 +25,19 @@ export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [modalCount, setModalCount] = useState(0);
const openModal = () => {
setModalCount(prev => prev + 1);
setModalCount((prev) => prev + 1);
};
const closeModal = () => {
setModalCount(prev => Math.max(0, prev - 1));
setModalCount((prev) => Math.max(0, prev - 1));
};
const isAnyModalOpen = modalCount > 0;
return (
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}>
<ModalContext.Provider
value={{ isAnyModalOpen, openModal, closeModal, modalCount }}
>
{children}
</ModalContext.Provider>
);

View file

@ -1,4 +1,4 @@
import { Task } from "./Task";
import { Task } from './Task';
export interface WeeklyCompletion {
date: string;

View file

@ -1,4 +1,4 @@
import { Tag } from "./Tag";
import { Tag } from './Tag';
export interface Note {
id?: number;

View file

@ -1,7 +1,7 @@
import { Area } from "./Area";
import { Tag } from "./Tag";
import { PriorityType, Task } from "./Task";
import { Note } from "./Note";
import { Area } from './Area';
import { Tag } from './Tag';
import { PriorityType, Task } from './Task';
import { Note } from './Note';
export interface Project {
id?: number;

View file

@ -1,5 +1,5 @@
import { Tag } from "./Tag";
import { Project } from "./Project";
import { Tag } from './Tag';
import { Project } from './Project';
export interface Task {
id?: number;
@ -29,4 +29,10 @@ export interface Task {
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';
export type PriorityType = 'low' | 'medium' | 'high';
export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day';
export type RecurrenceType =
| 'none'
| 'daily'
| 'weekly'
| 'monthly'
| 'monthly_weekday'
| 'monthly_last_day';

View file

@ -2,10 +2,22 @@ export interface TaskEvent {
id: number;
task_id: number;
user_id: number;
event_type: 'created' | 'status_changed' | 'priority_changed' | 'due_date_changed' |
'project_changed' | 'name_changed' | 'description_changed' | 'note_changed' |
'completed' | 'archived' | 'deleted' | 'restored' | 'today_changed' |
'tags_changed' | 'recurrence_changed';
event_type:
| 'created'
| 'status_changed'
| 'priority_changed'
| 'due_date_changed'
| 'project_changed'
| 'name_changed'
| 'description_changed'
| 'note_changed'
| 'completed'
| 'archived'
| 'deleted'
| 'restored'
| 'today_changed'
| 'tags_changed'
| 'recurrence_changed';
old_value?: any;
new_value?: any;
field_name?: string;

View file

@ -24,18 +24,21 @@ const fallbackResources = {
},
};
const devResources = isDevelopment ? {
const devResources = isDevelopment
? {
en: {
translation: fallbackResources.en.translation,
},
} : undefined;
}
: undefined;
const i18nInstance = i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next);
i18nInstance.init({
i18nInstance
.init({
fallbackLng: 'en',
debug: isDevelopment,
load: 'languageOnly',
@ -47,7 +50,7 @@ i18nInstance.init({
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie']
caches: ['localStorage', 'cookie'],
},
interpolation: {
escapeValue: false,
@ -60,41 +63,69 @@ i18nInstance.init({
requestOptions: {
cache: 'default',
credentials: 'same-origin',
mode: 'cors'
}
mode: 'cors',
},
},
})
.then(() => {
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`;
const loadPath = isDevelopment
? `./locales/${i18n.language}/translation.json`
: `/locales/${i18n.language}/translation.json`;
fetch(loadPath)
.then(response => {
.then((response) => {
if (!response.ok) {
if (isDevelopment) {
return fetch(`/locales/${i18n.language}/translation.json`);
return fetch(
`/locales/${i18n.language}/translation.json`
);
}
throw new Error(`Failed to fetch translation: ${response.status}`);
throw new Error(
`Failed to fetch translation: ${response.status}`
);
}
return response.json();
})
.then(data => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
})
.catch(() => {
if (isDevelopment) {
try {
setTimeout(() => {
fetch(`/locales/${i18n.language}/translation.json`, {
headers: { 'Accept': 'application/json' },
mode: 'cors'
fetch(
`/locales/${i18n.language}/translation.json`,
{
headers: { Accept: 'application/json' },
mode: 'cors',
}
)
.then((res) => res.json())
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
})
.then(res => res.json())
.then(data => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
})
.catch(() => {});
.catch((error) => {
console.error(
'Error loading translation:',
error
);
});
}, 1000);
} catch (e) {}
} catch (e) {
console.error('Error in retry mechanism:', e);
}
}
});
});
@ -105,7 +136,9 @@ i18n.on('failedLoading', () => {});
i18n.on('missingKey', () => {});
const dispatchLanguageChangeEvent = (lng: string) => {
const event = new CustomEvent('app-language-changed', { detail: { language: lng } });
const event = new CustomEvent('app-language-changed', {
detail: { language: lng },
});
window.dispatchEvent(event);
};
@ -128,20 +161,27 @@ i18n.on('languageChanged', (lng) => {
: `/locales/${lng}/translation.json`;
fetch(loadPath)
.then(response => {
.then((response) => {
if (!response.ok) {
return fetch(`/locales/${lng}/translation.json`);
}
return response;
})
.then(response => response.json())
.then(data => {
.then((response) => response.json())
.then((data) => {
if (data) {
i18n.addResourceBundle(lng, 'translation', data, true, true);
i18n.addResourceBundle(
lng,
'translation',
data,
true,
true
);
handleTranslationsLoaded();
}
})
.catch(() => {
.catch((error) => {
console.error('Error loading translations:', error);
handleTranslationsLoaded();
});
} else {
@ -164,7 +204,8 @@ window.checkTranslation = (key: string) => {
try {
const translation = i18n.t(key);
return translation;
} catch {
} catch (error) {
console.error('Error checking translation:', error);
return null;
}
};
@ -177,12 +218,23 @@ window.forceLanguageReload = (lng?: string) => {
dispatchLanguageChangeEvent(targetLng);
if (i18n.services && i18n.services.resourceStore) {
Object.values(i18n.services.resourceStore.data).forEach(lang => {
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) {
const temp = {...lang.translation as Record<string, unknown>};
Object.values(i18n.services.resourceStore.data).forEach(
(lang) => {
if (
lang.translation &&
typeof lang.translation === 'object' &&
lang.translation !== null
) {
const temp = {
...(lang.translation as Record<
string,
unknown
>),
};
lang.translation = temp;
}
});
}
);
}
if (lng) {
@ -191,7 +243,9 @@ window.forceLanguageReload = (lng?: string) => {
}, 50);
}
})
.catch(() => {});
.catch((error) => {
console.error('Error reloading language:', error);
});
};
export default i18n;

View file

@ -5,29 +5,31 @@ declare const module: {
};
};
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ToastProvider } from "./components/Shared/ToastContext";
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { ToastProvider } from './components/Shared/ToastContext';
import './i18n'; // Import i18n config to initialize it
import './styles/markdown.css'; // Import markdown styles
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n'; // Import the i18n instance with its configuration
const storedPreference = localStorage.getItem("isDarkMode");
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const storedPreference = localStorage.getItem('isDarkMode');
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
const isDarkMode = storedPreference
? storedPreference === "true"
? storedPreference === 'true'
: prefersDarkMode;
if (isDarkMode) {
document.documentElement.classList.add("dark");
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove('dark');
}
const container = document.getElementById("root");
const container = document.getElementById('root');
// Store the root outside the if block so it can be accessed by the HMR code
let root: any;

View file

@ -1,10 +1,10 @@
import { create } from "zustand";
import { Project } from "../entities/Project";
import { Area } from "../entities/Area";
import { Note } from "../entities/Note";
import { Task } from "../entities/Task";
import { Tag } from "../entities/Tag";
import { InboxItem } from "../entities/InboxItem";
import { create } from 'zustand';
import { Project } from '../entities/Project';
import { Area } from '../entities/Area';
import { Note } from '../entities/Note';
import { Task } from '../entities/Task';
import { Tag } from '../entities/Tag';
import { InboxItem } from '../entities/InboxItem';
interface NotesStore {
notes: Note[];
@ -78,42 +78,78 @@ export const useStore = create<StoreState>((set) => ({
notes: [],
isLoading: false,
isError: false,
setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })),
setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })),
setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })),
setNotes: (notes) =>
set((state) => ({ notesStore: { ...state.notesStore, notes } })),
setLoading: (isLoading) =>
set((state) => ({
notesStore: { ...state.notesStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ notesStore: { ...state.notesStore, isError } })),
},
areasStore: {
areas: [],
isLoading: false,
isError: false,
setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })),
setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })),
setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })),
setAreas: (areas) =>
set((state) => ({ areasStore: { ...state.areasStore, areas } })),
setLoading: (isLoading) =>
set((state) => ({
areasStore: { ...state.areasStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ areasStore: { ...state.areasStore, isError } })),
},
projectsStore: {
projects: [],
isLoading: false,
isError: false,
setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })),
setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })),
setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })),
setProjects: (projects) =>
set((state) => ({
projectsStore: { ...state.projectsStore, projects },
})),
setLoading: (isLoading) =>
set((state) => ({
projectsStore: { ...state.projectsStore, isLoading },
})),
setError: (isError) =>
set((state) => ({
projectsStore: { ...state.projectsStore, isError },
})),
},
tagsStore: {
tags: [],
isLoading: false,
isError: false,
setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
setTags: (tags) =>
set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
setLoading: (isLoading) =>
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
setError: (isError) =>
set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
loadTags: async () => {
const { fetchTags } = require("../utils/tagsService");
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading: true, isError: false } }));
const { fetchTags } = await import('../utils/tagsService');
set((state) => ({
tagsStore: {
...state.tagsStore,
isLoading: true,
isError: false,
},
}));
try {
const tags = await fetchTags();
set((state) => ({ tagsStore: { ...state.tagsStore, tags, isLoading: false } }));
set((state) => ({
tagsStore: { ...state.tagsStore, tags, isLoading: false },
}));
} catch (error) {
console.error("loadTags: Failed to load tags:", error);
set((state) => ({ tagsStore: { ...state.tagsStore, isError: true, isLoading: false } }));
console.error('loadTags: Failed to load tags:', error);
set((state) => ({
tagsStore: {
...state.tagsStore,
isError: true,
isLoading: false,
},
}));
}
},
},
@ -121,42 +157,55 @@ export const useStore = create<StoreState>((set) => ({
tasks: [],
isLoading: false,
isError: false,
setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })),
setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
setTasks: (tasks) =>
set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
setLoading: (isLoading) =>
set((state) => ({
tasksStore: { ...state.tasksStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
},
inboxStore: {
inboxItems: [],
isLoading: false,
isError: false,
setInboxItems: (inboxItems) => set((state) => ({
inboxStore: { ...state.inboxStore, inboxItems }
setInboxItems: (inboxItems) =>
set((state) => ({
inboxStore: { ...state.inboxStore, inboxItems },
})),
addInboxItem: (inboxItem) => set((state) => ({
addInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: [...state.inboxStore.inboxItems, inboxItem]
}
inboxItems: [...state.inboxStore.inboxItems, inboxItem],
},
})),
updateInboxItem: (inboxItem) => set((state) => ({
updateInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.map(item =>
inboxItems: state.inboxStore.inboxItems.map((item) =>
item.id === inboxItem.id ? inboxItem : item
)
}
),
},
})),
removeInboxItem: (id) => set((state) => ({
removeInboxItem: (id) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.filter(item => item.id !== id)
}
inboxItems: state.inboxStore.inboxItems.filter(
(item) => item.id !== id
),
},
})),
setLoading: (isLoading) => set((state) => ({
inboxStore: { ...state.inboxStore, isLoading }
setLoading: (isLoading) =>
set((state) => ({
inboxStore: { ...state.inboxStore, isLoading },
})),
setError: (isError) => set((state) => ({
inboxStore: { ...state.inboxStore, isError }
setError: (isError) =>
set((state) => ({
inboxStore: { ...state.inboxStore, isError },
})),
},
}));

View file

@ -1,11 +1,11 @@
import { Area } from "../entities/Area";
import { handleAuthResponse } from "./authUtils";
import { Area } from '../entities/Area';
import { handleAuthResponse } from './authUtils';
export const fetchAreas = async (): Promise<Area[]> => {
const response = await fetch("/api/areas?active=true", {
const response = await fetch('/api/areas?active=true', {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch areas.');
@ -18,7 +18,7 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(areaData),
});
@ -27,13 +27,16 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
return await response.json();
};
export const updateArea = async (areaId: number, areaData: Partial<Area>): Promise<Area> => {
export const updateArea = async (
areaId: number,
areaData: Partial<Area>
): Promise<Area> => {
const response = await fetch(`/api/areas/${areaId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(areaData),
});
@ -47,7 +50,7 @@ export const deleteArea = async (areaId: number): Promise<void> => {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});

View file

@ -1,8 +1,8 @@
export const getDefaultHeaders = (): Record<string, string> => {
return {
'Accept': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Origin': window.location.origin,
Origin: window.location.origin,
};
};
@ -15,7 +15,10 @@ export const getPostHeaders = (): Record<string, string> => {
let isRedirecting = false;
export const handleAuthResponse = async (response: Response, errorMessage: string): Promise<Response> => {
export const handleAuthResponse = async (
response: Response,
errorMessage: string
): Promise<Response> => {
if (!response.ok) {
if (response.status === 401) {
if (window.location.pathname !== '/login' && !isRedirecting) {

View file

@ -29,7 +29,10 @@ export const getCurrentLocale = (): Locale => {
* @param formatStr - The format string (https://date-fns.org/v2.29.3/docs/format)
* @returns The formatted date string
*/
export const formatLocalizedDate = (date: Date | number, formatStr: string): string => {
export const formatLocalizedDate = (
date: Date | number,
formatStr: string
): string => {
return format(date, formatStr, {
locale: getCurrentLocale(),
});
@ -42,7 +45,10 @@ export const formatLocalizedDate = (date: Date | number, formatStr: string): str
* @param fallback - Fallback format to use if translation is missing
* @returns The format pattern string
*/
export const getDateFormatPattern = (formatKey: string, fallback: string): string => {
export const getDateFormatPattern = (
formatKey: string,
fallback: string
): string => {
const pattern = i18n.t(`dateFormats.${formatKey}`);
// If the translation key doesn't exist, it will return the key itself
return pattern === `dateFormats.${formatKey}` ? fallback : pattern;
@ -56,7 +62,10 @@ export const getDateFormatPattern = (formatKey: string, fallback: string): strin
* @returns The formatted date string
*/
export const formatLongDate = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('long', 'EEEE, MMMM d, yyyy'));
return formatLocalizedDate(
date,
getDateFormatPattern('long', 'EEEE, MMMM d, yyyy')
);
};
/**
@ -67,7 +76,10 @@ export const formatLongDate = (date: Date | number): string => {
* @returns The formatted date string
*/
export const formatShortDate = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('short', 'MMM d, yyyy'));
return formatLocalizedDate(
date,
getDateFormatPattern('short', 'MMM d, yyyy')
);
};
/**
@ -78,7 +90,10 @@ export const formatShortDate = (date: Date | number): string => {
* @returns The formatted date string
*/
export const formatMonthYear = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('monthYear', 'MMMM yyyy'));
return formatLocalizedDate(
date,
getDateFormatPattern('monthYear', 'MMMM yyyy')
);
};
/**
@ -89,7 +104,10 @@ export const formatMonthYear = (date: Date | number): string => {
* @returns The formatted date string
*/
export const formatDayMonth = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('dayMonth', 'MMMM d'));
return formatLocalizedDate(
date,
getDateFormatPattern('dayMonth', 'MMMM d')
);
};
/**
@ -111,7 +129,10 @@ export const formatTime = (date: Date | number): string => {
* @returns The formatted date and time string
*/
export const formatDateTime = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('dateTime', 'MMM d, yyyy h:mm a'));
return formatLocalizedDate(
date,
getDateFormatPattern('dateTime', 'MMM d, yyyy h:mm a')
);
};
/**
@ -120,14 +141,26 @@ export const formatDateTime = (date: Date | number): string => {
* @param task - The task to check
* @returns True if the task is likely overdue in today plan, false otherwise
*/
export const isTaskOverdue = (task: { today?: boolean; created_at?: string; today_move_count?: number; status: string | number; completed_at?: string }): boolean => {
export const isTaskOverdue = (task: {
today?: boolean;
created_at?: string;
today_move_count?: number;
status: string | number;
completed_at?: string;
}): boolean => {
// If task is not in today plan, it's not overdue
if (!task.today) {
return false;
}
// Only hide overdue badge if task is actually completed (done/archived), not just in progress
if (task.completed_at || task.status === 'done' || task.status === 2 || task.status === 'archived' || task.status === 3) {
if (
task.completed_at ||
task.status === 'done' ||
task.status === 2 ||
task.status === 'archived' ||
task.status === 3
) {
return false;
}

View file

@ -2,13 +2,15 @@ export const fetcher = async (url: string) => {
const response = await fetch(url, {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
const error = new Error(errorData.error || 'An error occurred while fetching the data.');
const error = new Error(
errorData.error || 'An error occurred while fetching the data.'
);
(error as any).info = errorData;
(error as any).status = response.status;
throw error;

View file

@ -1,13 +1,13 @@
import { InboxItem } from "../entities/InboxItem";
import { useStore } from "../store/useStore";
import { handleAuthResponse } from "./authUtils";
import { InboxItem } from '../entities/InboxItem';
import { useStore } from '../store/useStore';
import { handleAuthResponse } from './authUtils';
// API functions
export const fetchInboxItems = async (): Promise<InboxItem[]> => {
const response = await fetch('/api/inbox', {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
@ -22,13 +22,16 @@ export const fetchInboxItems = async (): Promise<InboxItem[]> => {
return result;
};
export const createInboxItem = async (content: string, source?: string): Promise<InboxItem> => {
export const createInboxItem = async (
content: string,
source?: string
): Promise<InboxItem> => {
const response = await fetch('/api/inbox', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(source ? { content, source } : { content }),
});
@ -37,13 +40,16 @@ export const createInboxItem = async (content: string, source?: string): Promise
return await response.json();
};
export const updateInboxItem = async (itemId: number, content: string): Promise<InboxItem> => {
export const updateInboxItem = async (
itemId: number,
content: string
): Promise<InboxItem> => {
const response = await fetch(`/api/inbox/${itemId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ content }),
});
@ -57,7 +63,7 @@ export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
method: 'PATCH',
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
@ -70,7 +76,7 @@ export const deleteInboxItem = async (itemId: number): Promise<void> => {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
@ -78,6 +84,7 @@ export const deleteInboxItem = async (itemId: number): Promise<void> => {
};
// Track last check time to detect new items
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let lastCheckTimestamp = Date.now();
// Store-aware functions
@ -92,11 +99,14 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
const items = await fetchInboxItems();
// Check for new items since last check
const currentItemIds = new Set(inboxStore.inboxItems.map(item => item.id));
const currentItemIds = new Set(
inboxStore.inboxItems.map((item) => item.id)
);
const currentTime = Date.now();
// New telegram items
const newTelegramItems = items.filter(item =>
const newTelegramItems = items.filter(
(item) =>
item.id &&
!currentItemIds.has(item.id) &&
item.source === 'telegram'
@ -110,14 +120,17 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
// Get some minimal info about the items for the notification
const notificationData = {
count: newTelegramItems.length,
firstItemContent: newTelegramItems[0].content.substring(0, 30) +
(newTelegramItems[0].content.length > 30 ? '...' : '')
firstItemContent:
newTelegramItems[0].content.substring(0, 30) +
(newTelegramItems[0].content.length > 30 ? '...' : ''),
};
// Dispatch a custom event with the notification data
window.dispatchEvent(new CustomEvent('inboxItemsUpdated', {
detail: notificationData
}));
window.dispatchEvent(
new CustomEvent('inboxItemsUpdated', {
detail: notificationData,
})
);
}
// Update state and timestamp
@ -132,7 +145,10 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
}
};
export const createInboxItemWithStore = async (content: string, source?: string): Promise<InboxItem> => {
export const createInboxItemWithStore = async (
content: string,
source?: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
@ -145,7 +161,10 @@ export const createInboxItemWithStore = async (content: string, source?: string)
}
};
export const updateInboxItemWithStore = async (itemId: number, content: string): Promise<InboxItem> => {
export const updateInboxItemWithStore = async (
itemId: number,
content: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
@ -158,7 +177,9 @@ export const updateInboxItemWithStore = async (itemId: number, content: string):
}
};
export const processInboxItemWithStore = async (itemId: number): Promise<InboxItem> => {
export const processInboxItemWithStore = async (
itemId: number
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore;
try {
@ -171,7 +192,9 @@ export const processInboxItemWithStore = async (itemId: number): Promise<InboxIt
}
};
export const deleteInboxItemWithStore = async (itemId: number): Promise<void> => {
export const deleteInboxItemWithStore = async (
itemId: number
): Promise<void> => {
const inboxStore = useStore.getState().inboxStore;
try {

View file

@ -1,8 +1,12 @@
import { Note } from "../entities/Note";
import { handleAuthResponse, getDefaultHeaders, getPostHeaders } from "./authUtils";
import { Note } from '../entities/Note';
import {
handleAuthResponse,
getDefaultHeaders,
getPostHeaders,
} from './authUtils';
export const fetchNotes = async (): Promise<Note[]> => {
const response = await fetch("/api/notes", {
const response = await fetch('/api/notes', {
credentials: 'include',
headers: getDefaultHeaders(),
});
@ -12,7 +16,6 @@ export const fetchNotes = async (): Promise<Note[]> => {
};
export const createNote = async (noteData: Note): Promise<Note> => {
try {
const response = await fetch('/api/note', {
method: 'POST',
credentials: 'include',
@ -22,12 +25,12 @@ export const createNote = async (noteData: Note): Promise<Note> => {
await handleAuthResponse(response, 'Failed to create note.');
return await response.json();
} catch (error) {
throw error;
}
};
export const updateNote = async (noteId: number, noteData: Note): Promise<Note> => {
export const updateNote = async (
noteId: number,
noteData: Note
): Promise<Note> => {
const response = await fetch(`/api/note/${noteId}`, {
method: 'PATCH',
credentials: 'include',

View file

@ -1,4 +1,4 @@
import { handleAuthResponse } from "./authUtils";
import { handleAuthResponse } from './authUtils';
interface Profile {
id: number;
@ -35,20 +35,22 @@ export const fetchProfile = async (): Promise<Profile> => {
const response = await fetch('/api/profile', {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch profile data.');
return await response.json();
};
export const updateProfile = async (profileData: Partial<Profile>): Promise<Profile> => {
export const updateProfile = async (
profileData: Partial<Profile>
): Promise<Profile> => {
const response = await fetch('/api/profile', {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(profileData),
});
@ -60,7 +62,7 @@ export const fetchSchedulerStatus = async (): Promise<SchedulerStatus> => {
const response = await fetch('/api/profile/task-summary/status', {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch scheduler status.');
@ -73,7 +75,7 @@ export const sendTaskSummaryNow = async (): Promise<any> => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to send task summary.');
@ -84,20 +86,23 @@ export const fetchTelegramPollingStatus = async (): Promise<any> => {
const response = await fetch('/api/telegram/polling-status', {
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch polling status.');
return await response.json();
};
export const setupTelegram = async (botToken: string, chatId: string): Promise<TelegramBotInfo> => {
export const setupTelegram = async (
botToken: string,
chatId: string
): Promise<TelegramBotInfo> => {
const response = await fetch('/api/telegram/setup', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
bot_token: botToken,
@ -114,7 +119,7 @@ export const startTelegramPolling = async (): Promise<any> => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to start telegram polling.');
@ -127,20 +132,23 @@ export const stopTelegramPolling = async (): Promise<any> => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to stop telegram polling.');
return await response.json();
};
export const testTelegram = async (userId: number, message: string): Promise<any> => {
export const testTelegram = async (
userId: number,
message: string
): Promise<any> => {
const response = await fetch(`/api/telegram/test/${userId}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ text: message }),
});
@ -154,24 +162,29 @@ export const toggleTaskSummary = async (): Promise<any> => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to toggle task summary.');
return await response.json();
};
export const updateTaskSummaryFrequency = async (frequency: string): Promise<any> => {
export const updateTaskSummaryFrequency = async (
frequency: string
): Promise<any> => {
const response = await fetch('/api/profile/task-summary/frequency', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ frequency }),
});
await handleAuthResponse(response, 'Failed to update task summary frequency.');
await handleAuthResponse(
response,
'Failed to update task summary frequency.'
);
return await response.json();
};
@ -180,7 +193,9 @@ export type { Profile };
export const getTaskIntelligenceEnabled = async (): Promise<boolean> => {
try {
const profile = await fetchProfile();
return profile.task_intelligence_enabled !== undefined ? profile.task_intelligence_enabled : true;
return profile.task_intelligence_enabled !== undefined
? profile.task_intelligence_enabled
: true;
} catch (error) {
console.error('Error fetching task intelligence setting:', error);
return true; // Default to enabled if we can't fetch the setting
@ -190,9 +205,14 @@ export const getTaskIntelligenceEnabled = async (): Promise<boolean> => {
export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => {
try {
const profile = await fetchProfile();
return profile.auto_suggest_next_actions_enabled !== undefined ? profile.auto_suggest_next_actions_enabled : true;
return profile.auto_suggest_next_actions_enabled !== undefined
? profile.auto_suggest_next_actions_enabled
: true;
} catch (error) {
console.error('Error fetching auto-suggest next actions setting:', error);
console.error(
'Error fetching auto-suggest next actions setting:',
error
);
return true; // Default to enabled if we can't fetch the setting
}
};
@ -200,7 +220,9 @@ export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => {
export const getProductivityAssistantEnabled = async (): Promise<boolean> => {
try {
const profile = await fetchProfile();
return profile.productivity_assistant_enabled !== undefined ? profile.productivity_assistant_enabled : true;
return profile.productivity_assistant_enabled !== undefined
? profile.productivity_assistant_enabled
: true;
} catch (error) {
console.error('Error fetching productivity assistant setting:', error);
return true; // Default to enabled if we can't fetch the setting
@ -210,7 +232,9 @@ export const getProductivityAssistantEnabled = async (): Promise<boolean> => {
export const getNextTaskSuggestionEnabled = async (): Promise<boolean> => {
try {
const profile = await fetchProfile();
return profile.next_task_suggestion_enabled !== undefined ? profile.next_task_suggestion_enabled : true;
return profile.next_task_suggestion_enabled !== undefined
? profile.next_task_suggestion_enabled
: true;
} catch (error) {
console.error('Error fetching next task suggestion setting:', error);
return true; // Default to enabled if we can't fetch the setting

View file

@ -1,12 +1,15 @@
import { Project } from "../entities/Project";
import { handleAuthResponse } from "./authUtils";
import { Project } from '../entities/Project';
import { handleAuthResponse } from './authUtils';
export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise<Project[]> => {
export const fetchProjects = async (
activeFilter = 'all',
areaFilter = ''
): Promise<Project[]> => {
let url = `/api/projects`;
const params = new URLSearchParams();
if (activeFilter !== "all") params.append("active", activeFilter);
if (areaFilter) params.append("area_id", areaFilter);
if (activeFilter !== 'all') params.append('active', activeFilter);
if (areaFilter) params.append('area_id', areaFilter);
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, {
@ -20,16 +23,18 @@ export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Prom
return data.projects || data;
};
export const fetchGroupedProjects = async (activeFilter = "all", areaFilter = ""): Promise<Record<string, Project[]>> => {
export const fetchGroupedProjects = async (
activeFilter = 'all',
areaFilter = ''
): Promise<Record<string, Project[]>> => {
let url = `/api/projects`;
const params = new URLSearchParams();
params.append("grouped", "true");
if (activeFilter !== "all") params.append("active", activeFilter);
if (areaFilter) params.append("area_id", areaFilter);
params.append('grouped', 'true');
if (activeFilter !== 'all') params.append('active', activeFilter);
if (areaFilter) params.append('area_id', areaFilter);
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, {
credentials: 'include',
headers: { Accept: 'application/json' },
@ -51,13 +56,15 @@ export const fetchProjectById = async (projectId: string): Promise<Project> => {
return await response.json();
};
export const createProject = async (projectData: Partial<Project>): Promise<Project> => {
export const createProject = async (
projectData: Partial<Project>
): Promise<Project> => {
const response = await fetch('/api/project', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(projectData),
});
@ -66,13 +73,16 @@ export const createProject = async (projectData: Partial<Project>): Promise<Proj
return await response.json();
};
export const updateProject = async (projectId: number, projectData: Partial<Project>): Promise<Project> => {
export const updateProject = async (
projectId: number,
projectData: Partial<Project>
): Promise<Project> => {
const response = await fetch(`/api/project/${projectId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(projectData),
});
@ -86,7 +96,7 @@ export const deleteProject = async (projectId: number): Promise<void> => {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
},
});

Some files were not shown because too many files have changed in this diff Show more