From 5c427ef314b0731db28e235628e353b7816bf0e9 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 9 Jun 2025 07:30:00 +0300 Subject: [PATCH] I18n (#67) --- .gitignore | 3 +- Gemfile | 10 +- Gemfile.lock | 25 +- README.md | 75 +- app.rb | 37 +- app/config/database.yml | 2 +- app/frontend/App.tsx | 104 +- app/frontend/Layout.tsx | 64 +- app/frontend/components/Area/AreaDetails.tsx | 10 +- app/frontend/components/Area/AreaModal.tsx | 22 +- app/frontend/components/Areas.tsx | 20 +- .../components/Inbox/InboxItemDetail.tsx | 256 ++++ app/frontend/components/Inbox/InboxItems.tsx | 405 ++++++ app/frontend/components/Login.tsx | 17 +- app/frontend/components/Navbar.tsx | 6 +- app/frontend/components/Note/NoteModal.tsx | 46 +- app/frontend/components/Notes.tsx | 32 +- .../components/Profile/ProfileSettings.tsx | 751 +++++++++- .../Profile/ProfileSettings.tsx.bak | 1255 +++++++++++++++++ .../Profile/ProfileSettings.tsx.clean | 687 +++++++++ .../components/Project/ProjectItem.tsx | 14 +- .../components/Project/ProjectModal.tsx | 26 +- app/frontend/components/Projects.tsx | 34 +- .../components/Shared/ConfirmDialog.tsx | 11 +- .../components/Shared/LoadingScreen.tsx | 10 + .../components/Shared/PriorityDropdown.tsx | 16 +- .../components/Shared/StatusDropdown.tsx | 16 +- app/frontend/components/Sidebar.tsx | 4 +- .../Sidebar/CreateNewDropdownButton.tsx | 20 +- .../components/Sidebar/SidebarAreas.tsx | 8 +- .../components/Sidebar/SidebarNav.tsx | 67 +- .../components/Sidebar/SidebarNotes.tsx | 4 +- .../components/Sidebar/SidebarProjects.tsx | 4 +- .../components/Sidebar/SidebarTags.tsx | 9 +- app/frontend/components/Tag/TagDetails.tsx | 16 +- app/frontend/components/Tag/TagInput.tsx | 59 +- app/frontend/components/Tag/TagModal.tsx | 20 +- app/frontend/components/Task/NewTask.tsx | 10 +- .../components/Task/SimplifiedTaskModal.tsx | 183 +++ app/frontend/components/Task/TaskActions.tsx | 9 +- app/frontend/components/Task/TaskDueDate.tsx | 8 +- app/frontend/components/Task/TaskModal.tsx | 30 +- .../components/Task/TaskPriorityIcon.tsx | 2 + .../components/Task/TaskStatusBadge.tsx | 12 +- app/frontend/components/Task/TasksToday.tsx | 333 +++-- .../components/Task/getDescription.ts | 99 +- .../components/Task/getTitleAndIcon.ts | 73 +- app/frontend/components/Tasks.tsx | 35 +- app/frontend/entities/InboxItem.ts | 8 + app/frontend/entities/User.ts | 5 +- app/frontend/i18n.ts | 299 ++++ app/frontend/index.tsx | 46 +- app/frontend/store/useStore.ts | 48 + app/frontend/styles/tailwind.css | 15 + app/frontend/utils/dateUtils.ts | 115 ++ app/frontend/utils/inboxService.ts | 165 +++ app/frontend/utils/notesService.ts | 28 +- app/frontend/utils/tagsService.ts | 19 +- app/frontend/utils/urlService.ts | 71 + app/models/inbox_item.rb | 22 + app/models/task.rb | 47 +- app/models/user.rb | 4 + app/routes/authentication_routes.rb | 4 +- app/routes/inbox_routes.rb | 92 ++ app/routes/notes_routes.rb | 24 +- app/routes/tags_routes.rb | 1 + app/routes/telegram_poller.rb | 231 +++ app/routes/telegram_routes.rb | 160 +++ app/routes/url_routes.rb | 43 + app/routes/users_routes.rb | 131 +- app/services/task_summary_service.rb | 288 ++++ app/services/url_title_extractor_service.rb | 71 + config/initializers/scheduler.rb | 184 +++ config/initializers/telegram_initializer.rb | 51 + config/quotes.yml | 22 + cookies.txt | 5 + .../20250414134722_create_inbox_items.rb | 11 + ...50414150330_add_telegram_token_to_users.rb | 6 + ...0250416231240_add_task_summary_to_users.rb | 7 + ..._add_task_summary_run_tracking_to_users.rb | 7 + db/schema.rb | 25 +- index.html | 6 +- package-lock.json | 151 +- package.json | 7 +- ...pp_frontend_components_Tasks_tsx.bundle.js | 92 ++ ...app_frontend_utils_urlService_ts.bundle.js | 22 + public/js/bundle.js | 936 ++++++++++-- ...s-fetch_dist_browser-ponyfill_js.bundle.js | 21 + public/locales/de/translation.json | 63 + public/locales/el/translation.json | 394 ++++++ public/locales/en/translation.json | 406 ++++++ public/locales/es/translation.json | 176 +++ public/locales/jp/translation.json | 229 +++ public/locales/ua/translation.json | 111 ++ public/translation-test.html | 79 ++ run.sh | 35 + .../components/Inbox/InboxItemDetail.tsx | 0 test-i18n.html | 48 + test.js | 1 + translation.json | 92 ++ webpack.config.js | 32 +- 101 files changed, 9413 insertions(+), 702 deletions(-) create mode 100644 app/frontend/components/Inbox/InboxItemDetail.tsx create mode 100644 app/frontend/components/Inbox/InboxItems.tsx create mode 100644 app/frontend/components/Profile/ProfileSettings.tsx.bak create mode 100644 app/frontend/components/Profile/ProfileSettings.tsx.clean create mode 100644 app/frontend/components/Shared/LoadingScreen.tsx create mode 100644 app/frontend/components/Task/SimplifiedTaskModal.tsx create mode 100644 app/frontend/entities/InboxItem.ts create mode 100644 app/frontend/i18n.ts create mode 100644 app/frontend/utils/dateUtils.ts create mode 100644 app/frontend/utils/inboxService.ts create mode 100644 app/frontend/utils/urlService.ts create mode 100644 app/models/inbox_item.rb create mode 100644 app/routes/inbox_routes.rb create mode 100644 app/routes/telegram_poller.rb create mode 100644 app/routes/telegram_routes.rb create mode 100644 app/routes/url_routes.rb create mode 100644 app/services/task_summary_service.rb create mode 100644 app/services/url_title_extractor_service.rb create mode 100644 config/initializers/scheduler.rb create mode 100644 config/initializers/telegram_initializer.rb create mode 100644 config/quotes.yml create mode 100644 cookies.txt create mode 100644 db/migrate/20250414134722_create_inbox_items.rb create mode 100644 db/migrate/20250414150330_add_telegram_token_to_users.rb create mode 100644 db/migrate/20250416231240_add_task_summary_to_users.rb create mode 100644 db/migrate/20250416235420_add_task_summary_run_tracking_to_users.rb create mode 100644 public/js/app_frontend_components_Tasks_tsx.bundle.js create mode 100644 public/js/app_frontend_utils_urlService_ts.bundle.js create mode 100644 public/js/vendors-node_modules_cross-fetch_dist_browser-ponyfill_js.bundle.js create mode 100644 public/locales/de/translation.json create mode 100644 public/locales/el/translation.json create mode 100644 public/locales/en/translation.json create mode 100644 public/locales/es/translation.json create mode 100644 public/locales/jp/translation.json create mode 100644 public/locales/ua/translation.json create mode 100644 public/translation-test.html create mode 100644 src/app/frontend/components/Inbox/InboxItemDetail.tsx create mode 100644 test-i18n.html create mode 100644 test.js create mode 100644 translation.json diff --git a/.gitignore b/.gitignore index 8cb9cf1..816b011 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ certs/ node_modules .env -public/js/bundle.js \ No newline at end of file +public/js/bundle.js +.aider* diff --git a/Gemfile b/Gemfile index b48e735..2f2d0d7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,9 @@ source 'https://rubygems.org' gem 'puma' -gem 'rake' -gem 'sinatra' +gem 'rack-protection', '~> 3.1.0' +gem 'rake', '~> 13.0' +gem 'rufus-scheduler', '~> 3.8.2' # DB gem 'sinatra-activerecord' @@ -11,10 +12,11 @@ gem 'sinatra-namespace' gem 'sqlite3' # Authentication -gem 'bcrypt' +gem 'bcrypt', '~> 3.1' # Other -gem 'byebug' +gem 'byebug', '~> 11.1' +gem 'nokogiri', '~> 1.15' gem 'rerun' # Development diff --git a/Gemfile.lock b/Gemfile.lock index 39d834f..c27576a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -26,9 +26,14 @@ GEM connection_pool (2.4.1) drb (2.2.0) ruby2_keywords + et-orbi (1.2.11) + tzinfo faker (3.2.2) i18n (>= 1.8.11, < 2) ffi (1.16.3) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) i18n (1.14.1) concurrent-ruby (~> 1.0) json (2.6.3) @@ -42,12 +47,17 @@ GEM ruby2_keywords (~> 0.0.1) mutex_m (0.2.0) nio4r (2.5.9) + nokogiri (1.18.8-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-gnu) + racc (~> 1.4) parallel (1.23.0) parser (3.2.2.4) ast (~> 2.4.1) racc puma (6.4.0) nio4r (~> 2.0) + raabro (1.4.0) racc (1.7.3) rack (2.2.8) rack-protection (3.1.0) @@ -55,7 +65,7 @@ GEM rack-test (2.1.0) rack (>= 1.3) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -78,6 +88,8 @@ GEM parser (>= 3.2.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + rufus-scheduler (3.8.2) + fugit (~> 1.1, >= 1.1.6) sinatra (3.1.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) @@ -105,19 +117,22 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-24 x86_64-linux DEPENDENCIES - bcrypt - byebug + bcrypt (~> 3.1) + byebug (~> 11.1) faker minitest + nokogiri (~> 1.15) puma + rack-protection (~> 3.1.0) rack-test - rake + rake (~> 13.0) rerun rubocop - sinatra + rufus-scheduler (~> 3.8.2) sinatra-activerecord sinatra-cross_origin sinatra-namespace diff --git a/README.md b/README.md index 8e45136..c6fe3aa 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ This app allows users to manage their tasks, projects, areas, notes, and tags in - **Area Categorization**: Group projects into areas for better organization and focus. - **Due Date Tracking**: Set due dates for tasks and view them based on due date categories. - **Responsive Design**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones. +- **Multi-Language Support**: Available in multiple languages including English, German (de), Greek (el), Spanish (es), Japanese (jp), and Ukrainian (ua) among others. +- **Telegram Integration**: + - Create tasks directly through Telegram messages + - Receive daily digests of your tasks + - Quick capture of ideas and todos on the go ## 🗺️ Roadmap @@ -40,31 +45,57 @@ First pull the latest image: docker pull chrisvel/tududi:latest ``` -Then set up the necessary environment variables: +### ⚙️ Environment Variables -- `TUDUDI_USER_EMAIL` -- `TUDUDI_USER_PASSWORD` -- `TUDUDI_SESSION_SECRET` -- `TUDUDI_INTERNAL_SSL_ENABLED` +The following environment variables are used to configure tududi: -1. (Optional) Create a random session secret: - ```bash - openssl rand -hex 64 - ``` +#### Required Variables: +- `TUDUDI_USER_EMAIL` - Initial admin user's email address (e.g., `admin@example.com`) +- `TUDUDI_USER_PASSWORD` - Initial admin user's password (use a strong password!) +- `TUDUDI_SESSION_SECRET` - Session encryption key (generate with `openssl rand -hex 64`) -2. Run the Docker container: - ```bash - docker run \ - -e TUDUDI_USER_EMAIL=myemail@example.com \ - -e TUDUDI_USER_PASSWORD=mysecurepassword \ - -e TUDUDI_SESSION_SECRET=your_generated_hash_here \ - -e TUDUDI_INTERNAL_SSL_ENABLED=false \ - -v ~/tududi_db:/usr/src/app/tududi_db \ - -p 9292:9292 \ - -d chrisvel/tududi:latest - ``` +#### Optional Variables: +- `TUDUDI_INTERNAL_SSL_ENABLED` - Set to 'true' if using HTTPS internally (default: false) +- `TUDUDI_ALLOWED_ORIGINS` - Controls CORS access for different deployment scenarios: + - Not set: Only allows localhost origins + - Specific domains: `https://tududi.com,http://localhost:9292` + - Allow all (development only): Set to empty string `""` -3. Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials. +#### Common Configuration Examples: + +##### Local Development +```bash +export TUDUDI_USER_EMAIL=dev@local.test +export TUDUDI_USER_PASSWORD=devpassword123 +export TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) +export TUDUDI_INTERNAL_SSL_ENABLED=false +# TUDUDI_ALLOWED_ORIGINS not set - defaults to localhost only +``` + +##### Production with Reverse Proxy +```bash +export TUDUDI_USER_EMAIL=admin@yourdomain.com +export TUDUDI_USER_PASSWORD=your-secure-password-here +export TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) +export TUDUDI_INTERNAL_SSL_ENABLED=true +export TUDUDI_ALLOWED_ORIGINS=https://tududi.yourdomain.com +``` + +### 🚀 Running with Docker + +```bash +docker run \ + -e TUDUDI_USER_EMAIL=myemail@example.com \ + -e TUDUDI_USER_PASSWORD=mysecurepassword \ + -e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \ + -e TUDUDI_INTERNAL_SSL_ENABLED=false \ + -e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:9292 \ + -v ~/tududi_db:/usr/src/app/tududi_db \ + -p 9292:9292 \ + -d chrisvel/tududi:latest +``` + +Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials. ## 🚧 Development @@ -150,7 +181,7 @@ Contributions to `tududi` are welcome. To contribute: 2. Create a new branch (\`git checkout -b feature/AmazingFeature\`). 3. Make your changes. 4. Commit your changes (\`git commit -m 'Add some AmazingFeature'\`). -5. Push to the branch (\`git push origin feature/AmazingFeature\`). +5. Push to the branch (\`git push origin fexature/AmazingFeature\`). 6. Open a pull request. ## 📜 License diff --git a/app.rb b/app.rb index 0dc0ac5..a44ab6b 100644 --- a/app.rb +++ b/app.rb @@ -3,15 +3,23 @@ require 'sinatra/activerecord' require 'securerandom' require 'byebug' +# Models require './app/models/user' require './app/models/area' require './app/models/project' require './app/models/task' require './app/models/tag' require './app/models/note' +require './app/models/inbox_item' +# Services +require './app/services/task_summary_service' +require './app/services/url_title_extractor_service' +require './config/initializers/scheduler' +require './config/initializers/telegram_initializer' + +# Helpers require './app/helpers/authentication_helper' - require './app/routes/authentication_routes' require './app/routes/tasks_routes' require './app/routes/projects_routes' @@ -19,6 +27,10 @@ require './app/routes/areas_routes' require './app/routes/notes_routes' require './app/routes/tags_routes' require './app/routes/users_routes' +require './app/routes/inbox_routes' +require './app/routes/telegram_poller' +require './app/routes/telegram_routes' +require './app/routes/url_routes' require 'sinatra/cross_origin' @@ -39,6 +51,9 @@ configure do same_site: secure_flag ? :none : :lax set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) } + # Ensure ActiveRecord connection is established + ActiveRecord::Base.establish_connection + # Auto-create user if not exists if ENV['TUDUDI_USER_EMAIL'] && ENV['TUDUDI_USER_PASSWORD'] && ActiveRecord::Base.connection.table_exists?('users') user = User.find_or_initialize_by(email: ENV['TUDUDI_USER_EMAIL']) @@ -47,9 +62,12 @@ configure do user.save end end + + # Initialize the Telegram polling after database is ready + initialize_telegram_polling end -use Rack::Protection +use Rack::Protection, except: [:http_origin] before do require_login @@ -60,8 +78,21 @@ configure do end before do - response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080' + allowed_origins = ['http://localhost:8080', 'http://localhost:9292'] + + if ENV['TUDUDI_ALLOWED_ORIGINS'] + if ENV['TUDUDI_ALLOWED_ORIGINS'].strip.empty? + response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN'] + else + allowed_origins.concat(ENV['TUDUDI_ALLOWED_ORIGINS'].split(',').map(&:strip)) + if request.env['HTTP_ORIGIN'] && allowed_origins.include?(request.env['HTTP_ORIGIN']) + response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN'] + end + end + end + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept, X-Requested-With' end options '*' do diff --git a/app/config/database.yml b/app/config/database.yml index fed47d0..3eea98c 100644 --- a/app/config/database.yml +++ b/app/config/database.yml @@ -1,7 +1,7 @@ # config/database.yml default: &default adapter: sqlite3 - pool: 5 + pool: 15 timeout: 5000 development: diff --git a/app/frontend/App.tsx b/app/frontend/App.tsx index 7955662..238b8af 100644 --- a/app/frontend/App.tsx +++ b/app/frontend/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, Suspense, lazy } from "react"; import { Routes, Route, @@ -6,8 +6,8 @@ import { Navigate, useLocation, } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import Login from "./components/Login"; -import Tasks from "./components/Tasks"; import NotFound from "./components/Shared/NotFound"; import ProjectDetails from "./components/Project/ProjectDetails"; import Projects from "./components/Projects"; @@ -20,15 +20,51 @@ import NoteDetails from "./components/Note/NoteDetails"; import ProfileSettings from "./components/Profile/ProfileSettings"; import Layout from "./Layout"; import { User } from "./entities/User"; -import TasksToday from "./components/Task/TasksToday"; +import TasksToday from "./components/Task/TasksToday"; +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 App: React.FC = () => { + const { t, i18n } = useTranslation(); + + if (!i18n.isInitialized) { + return ; + } + const [currentUser, setCurrentUser] = useState(null); const [loading, setLoading] = useState(true); const navigate = useNavigate(); const location = useLocation(); useEffect(() => { + console.log("App component - i18n initialized:", i18n.isInitialized); + console.log("App component - Current language:", i18n.language); + console.log("App component - Has translation loaded:", i18n.hasResourceBundle(i18n.language, 'translation')); + + // Force reload translations for the current language + if (i18n.isInitialized) { + // Create a direct fetch to verify the translation file is accessible + fetch(`/locales/${i18n.language}/translation.json`) + .then(response => { + console.log(`Translation file fetch response: ${response.status} ${response.statusText}`); + if (!response.ok) { + console.error(`Failed to fetch translation file: ${response.status} ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log("Translation file content retrieved manually:", Object.keys(data)); + // Force add the resource bundle + i18n.addResourceBundle(i18n.language, 'translation', data, true, true); + console.log("Resource bundle manually added for:", i18n.language); + }) + .catch(error => { + console.error("Error manually fetching translation file:", error); + }); + } + const fetchCurrentUser = async () => { try { const response = await fetch("/api/current_user", { @@ -40,6 +76,19 @@ const App: React.FC = () => { const data = await response.json(); if (data.user) { setCurrentUser(data.user); + + // Set the language based on user's profile if available + if (data.user.language) { + console.log("Setting language from user profile:", data.user.language); + i18n.changeLanguage(data.user.language) + .then(() => { + console.log("Language changed to:", i18n.language); + // After changing language, verify resource bundle + console.log("Has resource bundle after change:", + i18n.hasResourceBundle(i18n.language, 'translation')); + }) + .catch(err => console.error("Error changing language:", err)); + } } else { navigate("/login"); } @@ -89,29 +138,36 @@ const App: React.FC = () => { } }, [currentUser, location.pathname, navigate]); - if (loading) { - return ( -
-
- Loading... -
+ const LoadingComponent = () => ( +
+
+ {i18n.t('common.loading', 'Loading application... Please wait.')}
- ); +
+ ); + + if (loading) { + return ; } return ( - <> - {currentUser ? ( - - + }> + {currentUser ? ( + + } /> } /> - } /> + {i18n.t('common.loading', 'Loading...')}
}> + + + } /> + } /> } /> } /> } /> @@ -127,10 +183,10 @@ const App: React.FC = () => { } /> - ) : ( - - )} - + ) : ( + + )} + ); }; diff --git a/app/frontend/Layout.tsx b/app/frontend/Layout.tsx index 0eb6e9c..84be25e 100644 --- a/app/frontend/Layout.tsx +++ b/app/frontend/Layout.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import Navbar from "./components/Navbar"; import Sidebar from "./components/Sidebar"; import "./styles/tailwind.css"; @@ -6,8 +7,8 @@ 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 SimplifiedTaskModal from "./components/Task/SimplifiedTaskModal"; import TaskModal from "./components/Task/TaskModal"; - import { Note } from "./entities/Note"; import { Area } from "./entities/Area"; import { Tag } from "./entities/Tag"; @@ -36,11 +37,14 @@ const Layout: React.FC = ({ toggleDarkMode, children, }) => { + const { t } = useTranslation(); + 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 [selectedNote, setSelectedNote] = useState(null); const [selectedArea, setSelectedArea] = useState(null); @@ -90,9 +94,10 @@ const Layout: React.FC = ({ }, } = useStore(); - const [isSidebarOpen, setIsSidebarOpen] = useState( - window.innerWidth >= 1024 - ); + const openTaskModal = (type: 'simplified' | 'full' = 'simplified') => { + setIsTaskModalOpen(true); + setTaskModalType(type); + }; useEffect(() => { const handleResize = () => { @@ -143,10 +148,6 @@ const Layout: React.FC = ({ setSelectedNote(null); }; - const openTaskModal = () => { - setIsTaskModalOpen(true); - }; - const closeTaskModal = () => { setIsTaskModalOpen(false); setNewTask(null); @@ -311,7 +312,7 @@ const Layout: React.FC = ({ className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`} >
- Loading... + {t('common.loading')}
@@ -347,7 +348,7 @@ const Layout: React.FC = ({
-
Error fetching data.
+
{t('errors.somethingWentWrong')}
); @@ -389,14 +390,13 @@ const Layout: React.FC = ({ - {/* Floating Action Button */} - {/* Modals */} {isTaskModalOpen && ( - + ) : ( + {}} - projects={projects} - onCreateProject={handleCreateProject} - /> + }} + onSave={handleSaveTask} + onDelete={() => {}} + projects={projects} + onCreateProject={handleCreateProject} + /> + ) )} {isProjectModalOpen && ( diff --git a/app/frontend/components/Area/AreaDetails.tsx b/app/frontend/components/Area/AreaDetails.tsx index 0335601..e377cc7 100644 --- a/app/frontend/components/Area/AreaDetails.tsx +++ b/app/frontend/components/Area/AreaDetails.tsx @@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { useStore } from '../../store/useStore'; import { Area } from '../../entities/Area'; +import { useTranslation } from 'react-i18next'; const AreaDetails: React.FC = () => { + const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const { areas } = useStore((state) => state.areasStore); const [area, setArea] = useState(null); @@ -24,7 +26,7 @@ const AreaDetails: React.FC = () => { return (
- Loading area details... + {t('areas.loading')}
); @@ -34,7 +36,7 @@ const AreaDetails: React.FC = () => { return (
- {isError ? 'Error loading area details.' : 'Area not found.'} + {isError ? t('areas.error') : t('areas.notFound')}
); @@ -44,14 +46,14 @@ const AreaDetails: React.FC = () => {

- Area: {area?.name} + {t('areas.details')}: {area?.name}

{area?.description}

- View Projects in {area?.name} + {t('areas.viewProjects', { name: area?.name })}
diff --git a/app/frontend/components/Area/AreaModal.tsx b/app/frontend/components/Area/AreaModal.tsx index 3274061..72b264f 100644 --- a/app/frontend/components/Area/AreaModal.tsx +++ b/app/frontend/components/Area/AreaModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Area } from '../../entities/Area'; import { useToast } from '../Shared/ToastContext'; +import { useTranslation } from 'react-i18next'; interface AreaModalProps { isOpen: boolean; @@ -10,6 +11,7 @@ interface AreaModalProps { } const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) => { + const { t } = useTranslation(); const [formData, setFormData] = useState({ id: area?.id || 0, name: area?.name || '', @@ -79,7 +81,7 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) const handleSubmit = async () => { if (!formData.name.trim()) { - setError('Area name is required.'); + setError(t('errors.areaNameRequired')); return; } @@ -88,11 +90,11 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) try { await onSave(formData); - showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`); + showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated')); handleClose(); } catch (err) { setError((err as Error).message); - showErrorToast('Failed to save area.'); + showErrorToast(t('errors.failedToSaveArea')); } finally { setIsSubmitting(false); } @@ -132,14 +134,14 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) onChange={handleChange} required className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2" - placeholder="Enter area name" + placeholder={t('forms.areaNamePlaceholder')} /> {/* Area Description */}
@@ -208,7 +224,7 @@ const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) onClick={handleClose} className="px-4 py-2 text-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out" > - Cancel + {t('common.cancel')} diff --git a/app/frontend/components/Notes.tsx b/app/frontend/components/Notes.tsx index d947456..c51efa1 100644 --- a/app/frontend/components/Notes.tsx +++ b/app/frontend/components/Notes.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { BookOpenIcon, PencilSquareIcon, @@ -17,6 +18,7 @@ import { } from '../utils/notesService'; const Notes: React.FC = () => { + const { t } = useTranslation(); const [notes, setNotes] = useState([]); const [selectedNote, setSelectedNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); @@ -90,7 +92,7 @@ const Notes: React.FC = () => { return (
- Loading notes... + {t('notes.loading')}
); @@ -99,7 +101,7 @@ const Notes: React.FC = () => { if (isError) { return (
-
Error loading notes.
+
{t('notes.error')}
); } @@ -112,7 +114,7 @@ const Notes: React.FC = () => {

- Notes + {t('notes.title')}

@@ -123,7 +125,7 @@ const Notes: React.FC = () => { setSearchQuery(e.target.value)} className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" @@ -133,13 +135,13 @@ const Notes: React.FC = () => { {/* Notes List */} {filteredNotes.length === 0 ? ( -

No notes found.

+

{t('notes.noNotesFound')}

) : ( -
    +
      {filteredNotes.map((note) => (
    • { > {note.title} -

      - {note.content} -

      @@ -166,9 +165,8 @@ const Notes: React.FC = () => { setNoteToDelete(note); setIsConfirmDialogOpen(true); }} - className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none" - aria-label={`Delete ${note.title}`} - title={`Delete ${note.title}`} + aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })} + title={t('notes.deleteNoteTitle', { noteTitle: note.title })} > @@ -191,8 +189,8 @@ const Notes: React.FC = () => { {/* ConfirmDialog */} {isConfirmDialogOpen && noteToDelete && ( setIsConfirmDialogOpen(false)} /> diff --git a/app/frontend/components/Profile/ProfileSettings.tsx b/app/frontend/components/Profile/ProfileSettings.tsx index 824168d..157c5c8 100644 --- a/app/frontend/components/Profile/ProfileSettings.tsx +++ b/app/frontend/components/Profile/ProfileSettings.tsx @@ -1,4 +1,8 @@ -import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react'; +import React, { useState, useEffect, ChangeEvent, FormEvent, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from 'i18next'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import { useToast } from '../Shared/ToastContext'; interface ProfileSettingsProps { currentUser: { id: number; email: string }; @@ -11,51 +15,304 @@ interface Profile { language: string; timezone: string; avatar_image: string | null; + telegram_bot_token: string | null; + telegram_chat_id: string | null; + task_summary_enabled: boolean; + task_summary_frequency: string; } +interface SchedulerStatus { + success: boolean; + enabled: boolean; + frequency: string; + last_run: string | null; + next_run: string | null; +} + +interface TelegramBotInfo { + username: string; + polling_status: any; + chat_url: string; +} + +// Helper functions +const capitalize = (str: string): string => { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +// Format frequency for display +const formatFrequency = (frequency: string): string => { + if (frequency.endsWith('h')) { + const value = frequency.replace('h', ''); + return `${value} ${parseInt(value) === 1 ? 'hour' : 'hours'}`; + } else if (frequency === 'daily') { + return '1 day'; + } else if (frequency === 'weekly') { + return '1 week'; + } else if (frequency === 'weekdays') { + return 'Weekdays'; + } + return frequency; +}; + +/** + * ProfileSettings Component + * Displays and manages user profile settings including appearance, language, + * timezone, telegram integration, and task summary settings. + */ const ProfileSettings: React.FC = ({ currentUser }) => { + const { t, i18n } = useTranslation(); + const { showSuccessToast, showErrorToast } = useToast(); + + // State variables const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState>({ appearance: 'light', language: 'en', timezone: 'UTC', avatar_image: '', + telegram_bot_token: '', }); - + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [updateKey, setUpdateKey] = useState(0); + const [isChangingLanguage, setIsChangingLanguage] = useState(false); + const [telegramBotToken, setTelegramBotToken] = useState(''); + const [telegramChatId, setTelegramChatId] = useState(''); + const [isTesting, setIsTesting] = useState(false); + const [isSendingSummary, setIsSendingSummary] = useState(false); + const [schedulerStatus, setSchedulerStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(false); + const [isPolling, setIsPolling] = useState(false); + const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [telegramError, setTelegramError] = useState(null); + const [telegramBotInfo, setTelegramBotInfo] = useState(null); + + // Force update function for language changes + const forceUpdate = useCallback(() => { + setUpdateKey(prevKey => prevKey + 1); + }, []); + + // Fetch scheduler status data + const fetchSchedulerStatus = async () => { + try { + setLoadingStatus(true); + const response = await fetch('/api/profile/task-summary/status'); + + if (!response.ok) { + throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.')); + } + + const data = await response.json(); + setSchedulerStatus(data); + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setLoadingStatus(false); + } + }; + + // Send task summary now + const handleSendTaskSummaryNow = async () => { + try { + setIsSendingSummary(true); + const response = await fetch('/api/profile/task-summary/send-now', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.sendSummaryFailed', 'Failed to send summary.')); + } + + const data = await response.json(); + showSuccessToast(data.message); + + // Fetch the updated scheduler status if enabled + if (data.enabled) { + fetchSchedulerStatus(); + } + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setIsSendingSummary(false); + } + }; + + // Handle form field changes + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // Handle language change immediately + if (name === 'language' && value !== i18n.language) { + handleLanguageChange(value); + } + }; + + const handleLanguageChange = async (value: string) => { + try { + setIsChangingLanguage(true); + console.log(`Changing language to: ${value}`); + + // Change the i18n language + await i18n.changeLanguage(value); + + // Explicitly force the document's lang attribute to match + document.documentElement.lang = value; + + // Verify translations are loaded + const resources = i18n.getResourceBundle(value, 'translation'); + console.log('Resources loaded for language:', value, resources ? 'Yes' : 'No'); + + if (!resources || Object.keys(resources).length === 0) { + console.warn('Translations might not be fully loaded for:', value); + + + // Try to load translations manually if needed + const loadPath = `/locales/${value}/translation.json`; + try { + const response = await fetch(loadPath); + if (response.ok) { + const data = await response.json(); + i18n.addResourceBundle(value, 'translation', data, true, true); + console.log('Manually loaded translations for:', value); + + // Force app to recognize new translations + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + } catch (err) { + console.error('Failed to manually load translations:', err); + } + } + + // Force another update to ensure UI reflects new language + setTimeout(() => { + forceUpdate(); + + // Try to load translations again if they still aren't available + const checkAndLoadResources = i18n.getResourceBundle(value, 'translation'); + if (!checkAndLoadResources || Object.keys(checkAndLoadResources).length === 0) { + console.warn('Still no translations after initial load, forcing reload'); + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + + // If change event wasn't fired, mark as complete after a delay + setTimeout(() => { + if (isChangingLanguage) { + setIsChangingLanguage(false); + } + }, 800); // Longer timeout to ensure translations load + }, 200); + } catch (error) { + console.error('Error changing language:', error); + setIsChangingLanguage(false); + } + }; + + // Fetch profile data when component mounts useEffect(() => { const fetchProfile = async () => { try { - const response = await fetch('/api/profile', { - headers: { Accept: 'application/json' }, - }); + setLoading(true); + const response = await fetch('/api/profile'); + if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to fetch profile.'); + throw new Error(t('profile.fetchError', 'Failed to fetch profile data.')); } - const data: Profile = await response.json(); + + const data = await response.json(); setProfile(data); setFormData({ - appearance: data.appearance, - language: data.language, - timezone: data.timezone, + appearance: data.appearance || 'light', + language: data.language || 'en', + timezone: data.timezone || 'UTC', avatar_image: data.avatar_image || '', + telegram_bot_token: data.telegram_bot_token || '', }); - } catch (err) { - setError((err as Error).message); + setTelegramBotToken(data.telegram_bot_token || ''); + setTelegramChatId(data.telegram_chat_id || ''); + + // Fetch scheduler status if task summaries are enabled + if (data.task_summary_enabled) { + fetchSchedulerStatus(); + } + + // If user has a token, check polling status + if (data.telegram_bot_token) { + fetchPollingStatus(); + } + } catch (error) { + showErrorToast((error as Error).message); } finally { setLoading(false); } }; + + const fetchPollingStatus = async () => { + try { + const response = await fetch('/api/telegram/polling-status'); + + if (!response.ok) { + throw new Error(t('profile.pollingStatusError', 'Failed to fetch polling status.')); + } + + const data = await response.json(); + setIsPolling(data.running); + + // If bot token exists but polling is not active, start polling automatically + if (data.token_exists && !data.running) { + console.log('Telegram bot token exists but polling not active. Starting polling automatically...'); + handleStartPolling(); + } + } catch (error) { + console.error('Error fetching polling status:', error); + } + }; fetchProfile(); }, []); - const handleChange = (e: ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; + // Add an effect to monitor language changes + useEffect(() => { + console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`); + }, [updateKey, i18n.language]); + + useEffect(() => { + const handleLanguageChanged = (lng: string) => { + console.log(`Language changed to ${lng}`); + // Force component to re-render when language changes + forceUpdate(); + }; + + // Handler for the custom app-language-changed event + const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => { + console.log('Custom language change event received:', event.detail.language); + // Force an update to re-render with new translations + forceUpdate(); + // Mark language change as complete after a short delay + // This ensures the UI has time to update with new translations + setTimeout(() => { + setIsChangingLanguage(false); + }, 300); + }; + + // Add language change listeners + i18n.on('languageChanged', handleLanguageChanged); + window.addEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + + // Clean up listeners on unmount + return () => { + i18n.off('languageChanged', handleLanguageChanged); + window.removeEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + }; + }, []); const handleAvatarChange = (e: ChangeEvent) => { if (e.target.files && e.target.files[0]) { @@ -66,6 +323,125 @@ const ProfileSettings: React.FC = ({ currentUser }) => { reader.readAsDataURL(e.target.files[0]); } }; + + const handleSetupTelegram = async () => { + setTelegramSetupStatus('loading'); + setTelegramError(null); + setTelegramBotInfo(null); + + try { + // Validate the token format + if (!formData.telegram_bot_token || !formData.telegram_bot_token.includes(':')) { + throw new Error(t('profile.invalidTelegramToken')); + } + + // Send setup request to the server + const response = await fetch('/api/telegram/setup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: formData.telegram_bot_token }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.telegramSetupFailed')); + } + + const data = await response.json(); + setTelegramSetupStatus('success'); + setSuccess(t('profile.telegramSetupSuccess')); + + // Save bot info for display + if (data.bot) { + setTelegramBotInfo(data.bot); + setIsPolling(true); + + // Explicitly verify polling is started + if (!data.bot.polling_status?.running) { + console.log('Polling not started automatically during setup. Starting manually...'); + // Small delay to ensure the server has registered the token + setTimeout(() => { + handleStartPolling(); + }, 1000); + } + } + + // Format the URL to start the bot chat + const botUsername = data.bot?.username || formData.telegram_bot_token.split(':')[0]; + + // Open the Telegram bot chat in a new window + window.open(`https://t.me/${botUsername}`, '_blank'); + + } catch (error) { + console.error('Telegram setup error:', error); + setTelegramSetupStatus('error'); + setTelegramError((error as Error).message); + } + }; + + const handleStartPolling = async () => { + try { + const response = await fetch('/api/telegram/start-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.startPollingFailed')); + } + + const data = await response.json(); + setIsPolling(true); + showSuccessToast(t('profile.pollingStarted')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Start polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; + + const handleStopPolling = async () => { + try { + const response = await fetch('/api/telegram/stop-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.stopPollingFailed')); + } + + const data = await response.json(); + setIsPolling(false); + showSuccessToast(t('profile.pollingStopped', 'Polling stopped successfully.')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Stop polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -90,7 +466,14 @@ const ProfileSettings: React.FC = ({ currentUser }) => { const updatedProfile: Profile = await response.json(); setProfile(updatedProfile); - setSuccess('Profile updated successfully.'); + + // Make sure to update language if it was changed + if (updatedProfile.language !== i18n.language) { + console.log('Updating language after form submission:', updatedProfile.language); + await i18n.changeLanguage(updatedProfile.language); + } + + setSuccess(t('profile.successMessage')); } catch (err) { setError((err as Error).message); } @@ -100,7 +483,7 @@ const ProfileSettings: React.FC = ({ currentUser }) => { return (
      - Loading profile settings... + {t('common.loading')}
      ); @@ -115,19 +498,18 @@ const ProfileSettings: React.FC = ({ currentUser }) => { } return ( -
      +

      - Profile Settings + {t('profile.title')}

      {success &&
      {success}
      } {error &&
      {error}
      }
      - {/* Appearance Selection */}
      - {/* Language Selection */}
      +

      + {t('profile.languageChangedNote', 'Language changes are applied immediately')} +

      + {isChangingLanguage && ( +
      + {t('profile.languageChanging', 'Changing language...')} +
      + )}
      - {/* Timezone Selection */}
      - - {/* Avatar Image Upload */} - {/*
      - - - {formData.avatar_image && ( - Avatar Preview +

      + {t('profile.telegramIntegration', 'Telegram Integration')} +

      + +
      + +

      + {t('profile.telegramDescription', 'Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.')} +

      +
      + +
      + + +

      + {t('profile.telegramTokenDescription', 'Create a bot with @BotFather on Telegram and paste the token here.')} +

      + + {profile?.telegram_chat_id && ( +
      +

      + {t('profile.telegramConnected', 'Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.')} +

      +
      )} -
      */} + + {telegramError && ( +
      +

      {telegramError}

      +
      + )} + + {telegramBotInfo && ( +
      +

      + {t('profile.botConfigured', 'Bot configured successfully!')} +

      + +
      +

      + {t('profile.botUsername', 'Bot Username:')} + @{telegramBotInfo.username} +

      + +
      +

      {t('profile.pollingStatus', 'Polling Status:')}

      + +
      +
      + {isPolling ? t('profile.pollingActive') : t('profile.pollingInactive')} +
      + +

      + {t('profile.pollingNote', 'Polling periodically checks for new messages from Telegram and adds them to your inbox.')} +

      + +
      + {isPolling ? ( + + ) : ( + + )} + + {t('profile.openTelegram', 'Open in Telegram')} + + + +
      +
      +
      +
      + )} + + +
      +

      + {t('profile.taskSummaryNotifications', 'Task Summary Notifications')} +

      + +
      + +

      + {t('profile.taskSummaryDescription', 'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.')} +

      +
      + +
      + +
      { + try { + const response = await fetch('/api/profile/task-summary/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.toggleFailed')); + } + + const data = await response.json(); + setProfile(prev => prev ? ({...prev, task_summary_enabled: data.enabled}) : null); + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } + }} + > + +
      +
      + +
      + +
      + {['1h', '2h', '4h', '8h', '12h', 'daily', 'weekly'].map((frequency) => ( + + ))} +
      +

      + {t('profile.frequencyHelp', 'Choose how often you want to receive task summaries.')} +

      +
      + +
      + + {(!profile?.telegram_bot_token || !profile?.telegram_chat_id) && ( +

      + {t('profile.telegramRequiredForSummaries', 'Telegram integration must be set up to use task summaries.')} +

      + )} +
      +
      - {/* Save Button */}
      diff --git a/app/frontend/components/Profile/ProfileSettings.tsx.bak b/app/frontend/components/Profile/ProfileSettings.tsx.bak new file mode 100644 index 0000000..6a2958d --- /dev/null +++ b/app/frontend/components/Profile/ProfileSettings.tsx.bak @@ -0,0 +1,1255 @@ +import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Switch } from '@headlessui/react'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; + +// Define interfaces for the component +interface ProfileData { + id: number; + name: string | null; + email: string; + appearance: string; + language: string; + timezone: string; + avatar_image?: string | null; + telegram_bot_token: string | null; + telegram_chat_id: string | null; + task_summary_enabled: boolean; + task_summary_frequency: string; +} + +interface ProfileFormData { + appearance: string; + language: string; + timezone: string; + avatar_image: string; + telegram_bot_token: string; +} + +interface SchedulerStatus { + success: boolean; + enabled: boolean; + frequency: string; + last_run: string | null; + next_run: string | null; +} + +interface TelegramBotInfo { + username: string; + polling_status: any; + chat_url: string; +} + +interface ProfileSettingsProps { + currentUser?: any; +} + +// Helper functions +const capitalize = (str: string): string => { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +// Toast utility +const useToast = () => { + const showSuccessToast = (message: string) => { + console.log('Success:', message); + // Implement toast notification + }; + + const showErrorToast = (message: string) => { + console.error('Error:', message); + // Implement toast notification + }; + + return { showSuccessToast, showErrorToast }; +}; + +/** + * ProfileSettings Component + * Displays and manages user profile settings including appearance, language, + * timezone, telegram integration, and task summary settings. + */ +const ProfileSettings: React.FC = ({ currentUser }) => { + const { t, i18n } = useTranslation(); + const { showSuccessToast, showErrorToast } = useToast(); + + // State variables + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [updateKey, setUpdateKey] = useState(0); + const [isChangingLanguage, setIsChangingLanguage] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [isSendingSummary, setIsSendingSummary] = useState(false); + const [schedulerStatus, setSchedulerStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(false); + const [isPolling, setIsPolling] = useState(false); + const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [telegramError, setTelegramError] = useState(null); + const [telegramBotInfo, setTelegramBotInfo] = useState(null); + + // Form data + const [formData, setFormData] = useState({ + appearance: 'light', + language: 'en', + timezone: 'UTC', + avatar_image: '', + telegram_bot_token: '', + }); + + // Force update function for language changes + const forceUpdate = () => setUpdateKey(prev => prev + 1); + // API functions + const fetchProfile = async () => { + try { + setLoading(true); + const response = await fetch('/api/profile', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to fetch profile.'); + } + + const data = await response.json(); + setProfile(data); + setFormData({ + appearance: data.appearance, + language: data.language, + timezone: data.timezone, + avatar_image: data.avatar_image || '', + telegram_bot_token: data.telegram_bot_token || '', + }); + + // Fetch scheduler status if task summaries are enabled + if (data.task_summary_enabled) { + fetchSchedulerStatus(); + } + + // If user has a token, check polling status + if (data.telegram_bot_token) { + fetchPollingStatus(); + } + } catch (error) { + console.error('Error fetching profile:', error); + setError((error as Error).message); + } finally { + setLoading(false); + } + }; + + // Fetch scheduler status (last run and next run times) + const fetchSchedulerStatus = async () => { + try { + setLoadingStatus(true); + const response = await fetch('/api/profile/task-summary/status'); + + if (!response.ok) { + throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.')); + } + + const data = await response.json(); + setSchedulerStatus(data); + } catch (error) { + console.error('Error fetching scheduler status:', error); + showErrorToast((error as Error).message); + } finally { + setLoadingStatus(false); + } + }; + + // Check Telegram polling status + const fetchPollingStatus = async () => { + try { + const response = await fetch('/api/telegram/polling-status'); + + if (!response.ok) { + throw new Error('Failed to fetch polling status'); + } + + const data = await response.json(); + setIsPolling(data.is_running || false); + + if (data.bot) { + setTelegramBotInfo({ + username: data.bot.username, + polling_status: data.status, + chat_url: `https://t.me/${data.bot.username}` + }); + } + + // Auto-start polling if needed + if (data.bot && !data.is_running) { + handleStartPolling(); + } + } catch (error) { + console.error('Error fetching polling status:', error); + } + }; + timezone: 'UTC', + avatar_image: '', + telegram_bot_token: '', + }); + + const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [telegramError, setTelegramError] = useState(null); + const [telegramBotInfo, setTelegramBotInfo] = useState<{ + username: string; + polling_status: any; + chat_url: string; + } | null>(null); + const [isPolling, setIsPolling] = useState(false); + + + const data = await response.json(); + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setIsTesting(false); + } + }; + + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.sendSummaryFailed', 'Failed to send summary.')); + } + + const data = await response.json(); + showSuccessToast(data.message); + // Refresh scheduler status after sending + fetchSchedulerStatus(); + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setIsSendingSummary(false); + } + }; + + const handleTaskSummaryToggle = async () => { + try { + const response = await fetch('/api/profile/task-summary/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.toggleFailed', 'Failed to toggle task summary.')); + } + + const data = await response.json(); + // Update the profile with the new setting + setProfile(prev => prev ? ({...prev, task_summary_enabled: data.enabled}) : null); + + // Fetch the updated scheduler status if enabled + if (data.enabled) { + fetchSchedulerStatus(); + } else { + setSchedulerStatus(null); + } + + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } + }; + + const handleStartPolling = async () => { + try { + const response = await fetch('/api/telegram/start-polling', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.startPollingFailed', 'Failed to start polling.')); + } + + const data = await response.json(); + setIsPolling(true); + showSuccessToast(t('profile.pollingStarted', 'Polling started successfully.')); + } catch (error) { + console.error('Start polling error:', error); + showErrorToast(t('profile.pollingError', 'Error with polling.')); + } + }; + + const handleStopPolling = async () => { + try { + const response = await fetch('/api/telegram/stop-polling', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.stopPollingFailed', 'Failed to stop polling.')); + } + + const data = await response.json(); + setIsPolling(false); + showSuccessToast(t('profile.pollingStopped', 'Polling stopped successfully.')); + } catch (error) { + console.error('Stop polling error:', error); + showErrorToast(t('profile.pollingError', 'Error with polling.')); + } + }; + + // Handle form field changes + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // Handle language change immediately + if (name === 'language' && value !== i18n.language) { + handleLanguageChange(value); + + // Update the profile with the new frequency + setProfile(prev => prev ? ({...prev, task_summary_frequency: frequency}) : null); + // Fetch updated scheduler status + fetchSchedulerStatus(); + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } + }} + > + {frequency.includes('h') + ? t(`profile.frequency.hourly`, `Every ${frequency.replace('h', ' hour')}${frequency === '1h' ? '' : 's'}`) + : t(`profile.frequency.${frequency}`, capitalize(frequency))} + + ))} +
      +

      + {t('profile.frequencyHelp', 'Choose how often you want to receive task summaries.')} +

      +
      + + {/* Scheduler Status */} + {schedulerStatus && ( +
      +

      + {t('profile.schedulerStatus', 'Scheduler Status')} +

      +
      +
      + {t('profile.lastRun', 'Last Run:')} +
      +
      + {schedulerStatus.last_run + ? new Date(schedulerStatus.last_run).toLocaleString() + : t('profile.neverRun', 'Never run')} +
      +
      + {t('profile.nextRun', 'Next Run:')} +
      +
      + {schedulerStatus.next_run + ? new Date(schedulerStatus.next_run).toLocaleString() + : t('profile.notScheduled', 'Not scheduled')} +
      +
      +

      + {t('profile.schedulerHelp', 'This shows when task summaries were last sent and when they will be sent next.')} +

      + +
      + )} + }} + > + {t('profile.checkStatus', 'Check scheduler status')} + + + {schedulerStatus && ( +
      +

      + {t('profile.schedulerStatus', 'Scheduler Status')} +

      +
      +
      + {t('profile.lastRun', 'Last Run:')} +
      +
      + {schedulerStatus.last_run + ? new Date(schedulerStatus.last_run).toLocaleString() + : t('profile.neverRun', 'Never run')} +
      +
      + {t('profile.nextRun', 'Next Run:')} +
      +
      + {schedulerStatus.next_run + ? new Date(schedulerStatus.next_run).toLocaleString() + : t('profile.notScheduled', 'Not scheduled')} +
      +
      +
      + )} + + )} +: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' + }`} + +

      + {t('profile.frequencyHelp', 'Choose how often you want to receive task summaries.')} +

      + + + {/* Scheduler Status */} + {schedulerStatus && ( +
      +

      + {t('profile.schedulerStatus', 'Scheduler Status')} +

      +
      +
      + {t('profile.lastRun', 'Last Run:')} +
      +
      + {schedulerStatus.last_run + ? new Date(schedulerStatus.last_run).toLocaleString() + : t('profile.neverRun', 'Never run')} +
      +
      + {t('profile.nextRun', 'Next Run:')} +
      +
      + {schedulerStatus.next_run + ? new Date(schedulerStatus.next_run).toLocaleString() + : t('profile.notScheduled', 'Not scheduled')} +
      +
      +

      + {t('profile.schedulerHelp', 'This shows when task summaries were last sent and when they will be sent next.')} +

      + +
      + )} +ta = await response.json(); + throw new Error(data.error || t('profile.toggleFailed')); + } + + const data = await response.json(); + // Update the profile with the new setting + setProfile(prev => prev ? ({...prev, task_summary_enabled: data.enabled}) : null); + + // Fetch the updated scheduler status if enabled + if (data.enabled) { + fetchSchedulerStatus(); + } else { + setSchedulerStatus(null); + } + + showSuccessToast(data.message); + // Fetch scheduler status if task summaries are enabled + if (data.task_summary_enabled) { + fetchSchedulerStatus(); + } + + // If user has a token, check polling status and start if not running + if (data.telegram_bot_token) { + console.log('User has Telegram token, checking polling status...'); + fetchPollingStatus(); + + // Also set an interval to check polling status every 30 seconds + // This ensures polling is restarted if it stops unexpectedly + const checkInterval = setInterval(() => { + if (data.telegram_bot_token) { + fetchPollingStatus(); + } + }, 30000); + + // Clean up interval on component unmount + return () => clearInterval(checkInterval); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + fetchProfile(); + }, []); + + // Fetch profile data when component mounts + useEffect(() => { + const fetchProfile = async () => { + try { + setLoading(true); + const response = await fetch('/api/profile'); + + if (!response.ok) { + throw new Error(t('profile.fetchError', 'Failed to fetch profile data.')); + } + + const data = await response.json(); + setProfile(data); + setTelegramBotToken(data.telegram_bot_token || ''); + setTelegramChatId(data.telegram_chat_id || ''); + + // Fetch scheduler status if task summaries are enabled + if (data.task_summary_enabled) { + fetchSchedulerStatus(); + } + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setLoading(false); + } + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setLoading(false); + } + }; + + const fetchSchedulerStatus = async () => { + try { + setLoadingStatus(true); + const response = await fetch('/api/profile/task-summary/status'); + + if (!response.ok) { + throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.')); + } + + const data = await response.json(); + setSchedul + console.log('Telegram bot token exists but polling not active. Starting polling automatically...'); + handleStartPolling(); + } + } + } catch (error) { + console.error('Error fetching polling status:', error); + } + }; + + // Add an effect to monitor language changes + // Add effect with the updateKey dependency to refresh component on language change + useEffect(() => { + console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`); + }, [updateKey, i18n.language]); + + useEffect(() => { + const handleLanguageChanged = (lng: string) => { + console.log(`Language changed to ${lng}`); + // Force component to re-render when language changes + forceUpdate(); + }; + + // Handler for the custom app-language-changed event + const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => { + console.log('Custom language change event received:', event.detail.language); + // Force an update to re-render with new translations + forceUpdate(); + // Mark language change as complete after a short delay + // This ensures the UI has time to update with new translations + setTimeout(() => { + setIsChangingLanguage(false); + }, 300); + }; + + // Add language change listeners + i18n.on('languageChanged', handleLanguageChanged); + window.addEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + + // Clean up listeners on unmount + return () => { + i18n.off('languageChanged', handleLanguageChanged); + window.removeEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + }; + }, []); + + const handleSendTelegramTest = async () => { + try { + setIsTesting(true); + const response = await fetch('/api/profile/telegram/test', { + method: 'POST' + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.telegramTestFailed', 'Failed to send test message.')); + } + + const data = await response.json(); + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setIsTesting(false); + } + }; + + const fetchSchedulerStatus = async () => { + try { + setLoadingStatus(true); + const response = await fetch('/api/profile/task-summary/status'); + + if (!response.ok) { + throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.')); + } + + const data = await response.json(); + setSchedulerStatus(data); + } catch (error) { + showErrorToast((error as Error).message); + } finally { + setLoadingStatus(false); + } + }; + + // Explicitly force the document's lang attribute to match + document.documentElement.lang = value; + + // Verify translations are loaded + const resources = i18n.getResourceBundle(value, 'translation'); + console.log('Resources loaded for language:', value, resources ? 'Yes' : 'No'); + + if (!resources || Object.keys(resources).length === 0) { + console.warn('Translations might not be fully loaded for:', value); + + // Try to load translations manually if needed + const loadPath = `/locales/${value}/translation.json`; + try { + const response = await fetch(loadPath); + if (response.ok) { + const data = await response.json(); + i18n.addResourceBundle(value, 'translation', data, true, true); + console.log('Manually loaded translations for:', value); + + // Force app to recognize new translations + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + } catch (err) { + console.error('Failed to manually load translations:', err); + } + } + + // Force another update to ensure UI reflects new language + setTimeout(() => { + forceUpdate(); + + // Try to load translations again if they still aren't available + const checkAndLoadResources = i18n.getResourceBundle(value, 'translation'); + if (!checkAndLoadResources || Object.keys(checkAndLoadResources).length === 0) { + console.warn('Still no translations after initial load, forcing reload'); + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + + // If change event wasn't fired, mark as complete after a delay + setTimeout(() => { + if (isChangingLanguage) { + setIsChangingLanguage(false); + } + }, 800); // Longer timeout to ensure translations load + }, 200); + } catch (error) { + console.error('Error changing language:', error); + setIsChangingLanguage(false); + } + } + }; + + const handleAvatarChange = (e: ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const reader = new FileReader(); + reader.onloadend = () => { + setFormData((prev) => ({ ...prev, avatar_image: reader.result as string })); + }; + reader.readAsDataURL(e.target.files[0]); + } + }; + + const handleSetupTelegram = async () => { + setTelegramSetupStatus('loading'); + setTelegramError(null); + setTelegramBotInfo(null); + + try { + // Validate the token format + if (!formData.telegram_bot_token || !formData.telegram_bot_token.includes(':')) { + throw new Error(t('profile.invalidTelegramToken')); + } + + // Send setup request to the server + const response = await fetch('/api/telegram/setup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: formData.telegram_bot_token }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.telegramSetupFailed')); + } + + const data = await response.json(); + setTelegramSetupStatus('success'); + setSuccess(t('profile.telegramSetupSuccess')); + + // Save bot info for display + if (data.bot) { + setTelegramBotInfo(data.bot); + setIsPolling(true); + + // Explicitly verify polling is started + if (!data.bot.polling_status?.running) { + console.log('Polling not started automatically during setup. Starting manually...'); + // Small delay to ensure the server has registered the token + setTimeout(() => { + handleStartPolling(); + }, 1000); + } + } + + // Format the URL to start the bot chat + const botUsername = data.bot?.username || formData.telegram_bot_token.split(':')[0]; + + // Open the Telegram bot chat in a new window + window.open(`https://t.me/${botUsername}`, '_blank'); + + } catch (error) { + console.error('Telegram setup error:', error); + setTelegramSetupStatus('error'); + setTelegramError((error as Error).message); + } + }; + + const handleStartPolling = async () => { + try { + const response = await fetch('/api/telegram/start-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.startPollingFailed')); + } + + const data = await response.json(); + setIsPolling(true); + showSuccessToast(t('profile.pollingStarted')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Start polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; + + const handleStopPolling = async () => { + try { + const response = await fetch('/api/telegram/stop-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.stopPollingFailed')); + } + + const data = await response.json(); + setIsPolling(false); + showSuccessToast(t('profile.pollingStopped')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Stop polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + try { + const response = await fetch('/api/profile', { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update profile.'); + } + + const updatedProfile: Profile = await response.json(); + setProfile(updatedProfile); + + // Make sure to update language if it was changed + if (updatedProfile.language !== i18n.language) { + console.log('Updating language after form submission:', updatedProfile.language); + await i18n.changeLanguage(updatedProfile.language); + } + + setSuccess(t('profile.successMessage')); + } catch (err) { + setError((err as Error).message); + } + }; + + if (loading) { + return ( +
      +
      + {t('common.loading')} +
      +
      + ); + } + + if (error) { + return ( +
      +
      {error}
      +
      + ); + } + + return ( +
      +

      + {t('profile.title')} +

      + + {/* Debug information */} + {process.env.NODE_ENV === 'development' && ( +
      +

      Current language: {i18n.language}

      +

      Initialized: {i18n.isInitialized ? 'Yes' : 'No'}

      +

      Available languages: {i18n.languages?.join(', ')}

      +
      + )} + + {success &&
      {success}
      } + {error &&
      {error}
      } + +
      + {/* Appearance Selection */} +
      + + +
      + + {/* Language Selection */} +
      + + +

      + {t('profile.languageChangedNote', 'Language changes are applied immediately')} +

      + {isChangingLanguage && ( +
      + {t('profile.languageChanging', 'Changing language...')} +
      + )} +
      + + {/* Timezone Selection */} +
      + + +
      + + {/* Telegram Integration */} +
      +

      + {t('profile.telegramIntegration', 'Telegram Integration')} +

      + +
      + +

      + {t('profile.telegramDescription', 'Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.')} +

      +
      + +
      + + +

      + {t('profile.telegramTokenDescription', 'Create a bot with @BotFather on Telegram and paste the token here.')} +

      +
      + + {profile?.telegram_chat_id && ( +
      +

      + {t('profile.telegramConnected', 'Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.')} +

      +
      + )} + + {telegramError && ( +
      +

      {telegramError}

      +
      + )} + + {telegramBotInfo && ( +
      +

      + {t('profile.botConfigured', 'Bot configured successfully!')} +

      + +
      +

      + {t('profile.botUsername', 'Bot Username:')} + @{telegramBotInfo.username} +

      + +
      +

      {t('profile.pollingStatus', 'Polling Status:')}

      + +
      +
      + {isPolling ? t('profile.pollingActive') : t('profile.pollingInactive')} +
      + +

      + {t('profile.pollingNote', 'Polling periodically checks for new messages from Telegram and adds them to your inbox.')} +

      + +
      + {isPolling ? ( + + ) : ( + + )} + + {t('profile.openTelegram', 'Open in Telegram')} + + + {/* Test button for development */} + +
      +
      +
      +
      + )} + + +
      + + {/* Task Summary Notifications */} +
      +

      + {t('profile.taskSummaryNotifications', 'Task Summary Notifications')} +

      + +
      + +

      + {t('profile.taskSummaryDescription', 'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.')} +

      +
      + + {/* Enable/Disable Toggle */} +
      + +
      { + try { + const response = await fetch('/api/profile/task-summary/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.toggleFailed')); + } + + const data = await response.json(); + // Update the profile with the new setting + setProfile(prev => prev ? ({...prev, task_summary_enabled: data.enabled}) : null); + showSuccessToast(data.message); + } catch (error) { + showErrorToast((error as Error).message); + } + }} + > + +
      +
      + + {/* Frequency Selection */} +
      + +
      + {['daily', 'weekdays', 'weekly'].map((frequency) => ( + + ))} +
      +

      + {t('profile.frequencyHelp', 'Choose how often you want to receive task summaries.')} +

      +
      + + {/* Test Button */} +
      + + {(!profile?.telegram_bot_token || !profile?.telegram_chat_id) && ( +

      + {t('profile.telegramRequiredForSummaries', 'Telegram integration must be set up to use task summaries.')} +

      + )} +
      +
      + + {/* Avatar Image Upload */} + {/*
      + + + {formData.avatar_image && ( + Avatar Preview + )} +
      */} + + {/* Save Button */} +
      + +
      +
      +
      + ); +}; + +export default ProfileSettings; diff --git a/app/frontend/components/Profile/ProfileSettings.tsx.clean b/app/frontend/components/Profile/ProfileSettings.tsx.clean new file mode 100644 index 0000000..9d85b28 --- /dev/null +++ b/app/frontend/components/Profile/ProfileSettings.tsx.clean @@ -0,0 +1,687 @@ +import React, { useState, useEffect, ChangeEvent, FormEvent, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from 'i18next'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import { useToast } from '../Shared/ToastContext'; +interface ProfileSettingsProps { + currentUser: { id: number; email: string }; +} + +interface Profile { + id: number; + email: string; + appearance: 'light' | 'dark'; + language: string; + timezone: string; + avatar_image: string | null; + telegram_bot_token: string | null; + telegram_chat_id: string | null; +} + +const ProfileSettings: React.FC = ({ currentUser }) => { + const { t, i18n } = useTranslation(); + const { showSuccessToast, showErrorToast } = useToast(); + // Add this to check the initial language + console.log('Current language on component mount:', i18n.language); + console.log('Available languages:', i18n.languages); + console.log('Available namespaces:', i18n.options.ns); + + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + // Use React's forceUpdate pattern with a function to guarantee a fresh render + const [updateKey, setUpdateKey] = useState(0); + const forceUpdate = useCallback(() => { + setUpdateKey(prev => prev + 1); + }, []); + + // Add a state for tracking if language is actively changing + const [isChangingLanguage, setIsChangingLanguage] = useState(false); + const [formData, setFormData] = useState({ + appearance: 'light', + language: 'en', + timezone: 'UTC', + avatar_image: '', + telegram_bot_token: '', + }); + + const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [telegramError, setTelegramError] = useState(null); + const [telegramBotInfo, setTelegramBotInfo] = useState<{ + username: string; + polling_status: any; + chat_url: string; + } | null>(null); + const [isPolling, setIsPolling] = useState(false); + + useEffect(() => { + const fetchProfile = async () => { + try { + const response = await fetch('/api/profile', { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to fetch profile.'); + } + const data: Profile = await response.json(); + setProfile(data); + setFormData({ + appearance: data.appearance, + language: data.language, + timezone: data.timezone, + avatar_image: data.avatar_image || '', + telegram_bot_token: data.telegram_bot_token || '', + }); + + // If user has a token, check polling status and start if not running + if (data.telegram_bot_token) { + console.log('User has Telegram token, checking polling status...'); + fetchPollingStatus(); + + // Also set an interval to check polling status every 30 seconds + // This ensures polling is restarted if it stops unexpectedly + const checkInterval = setInterval(() => { + if (data.telegram_bot_token) { + fetchPollingStatus(); + } + }, 30000); + + // Clean up interval on component unmount + return () => clearInterval(checkInterval); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + fetchProfile(); + }, []); + + // Fetch polling status + const fetchPollingStatus = async () => { + try { + const response = await fetch('/api/telegram/polling-status'); + const data = await response.json(); + + if (data.success) { + setIsPolling(data.is_polling); + + // If the user has a token and we've previously set up the bot + if (profile?.telegram_bot_token && telegramBotInfo?.username) { + // Update polling status in the bot info + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + + // If user has token but polling isn't active, start it automatically + if (profile?.telegram_bot_token && !data.is_polling) { + console.log('Telegram bot token exists but polling not active. Starting polling automatically...'); + handleStartPolling(); + } + } + } catch (error) { + console.error('Error fetching polling status:', error); + } + }; + + // Add an effect to monitor language changes + // Add effect with the updateKey dependency to refresh component on language change + useEffect(() => { + console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`); + }, [updateKey, i18n.language]); + + useEffect(() => { + const handleLanguageChanged = (lng: string) => { + console.log(`Language changed to ${lng}`); + // Force component to re-render when language changes + forceUpdate(); + }; + + // Handler for the custom app-language-changed event + const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => { + console.log('Custom language change event received:', event.detail.language); + // Force an update to re-render with new translations + forceUpdate(); + // Mark language change as complete after a short delay + // This ensures the UI has time to update with new translations + setTimeout(() => { + setIsChangingLanguage(false); + }, 300); + }; + + // Add language change listeners + i18n.on('languageChanged', handleLanguageChanged); + window.addEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + + // Clean up listeners on unmount + return () => { + i18n.off('languageChanged', handleLanguageChanged); + window.removeEventListener('app-language-changed', handleAppLanguageChanged as EventListener); + }; + }, []); + + const handleChange = async (e: ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // Change language immediately when selected + if (name === 'language' && value !== i18n.language) { + console.log('Changing language to:', value); + // Set flag to indicate language is changing + setIsChangingLanguage(true); + + try { + // Save language preference to localStorage for persistence + localStorage.setItem('i18nextLng', value); + + // First, force a re-render to indicate language is changing + forceUpdate(); + + // Trigger language change in i18next with a more robust approach + await i18n.changeLanguage(value); + console.log('Language changed successfully to:', i18n.language); + + // Explicitly force the document's lang attribute to match + document.documentElement.lang = value; + + // Verify translations are loaded + const resources = i18n.getResourceBundle(value, 'translation'); + console.log('Resources loaded for language:', value, resources ? 'Yes' : 'No'); + + if (!resources || Object.keys(resources).length === 0) { + console.warn('Translations might not be fully loaded for:', value); + + // Try to load translations manually if needed + const loadPath = `/locales/${value}/translation.json`; + try { + const response = await fetch(loadPath); + if (response.ok) { + const data = await response.json(); + i18n.addResourceBundle(value, 'translation', data, true, true); + console.log('Manually loaded translations for:', value); + + // Force app to recognize new translations + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + } catch (err) { + console.error('Failed to manually load translations:', err); + } + } + + // Force another update to ensure UI reflects new language + setTimeout(() => { + forceUpdate(); + + // Try to load translations again if they still aren't available + const checkAndLoadResources = i18n.getResourceBundle(value, 'translation'); + if (!checkAndLoadResources || Object.keys(checkAndLoadResources).length === 0) { + console.warn('Still no translations after initial load, forcing reload'); + if (window.forceLanguageReload) { + window.forceLanguageReload(value); + } + } + + // If change event wasn't fired, mark as complete after a delay + setTimeout(() => { + if (isChangingLanguage) { + setIsChangingLanguage(false); + } + }, 800); // Longer timeout to ensure translations load + }, 200); + } catch (error) { + console.error('Error changing language:', error); + setIsChangingLanguage(false); + } + } + }; + + const handleAvatarChange = (e: ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const reader = new FileReader(); + reader.onloadend = () => { + setFormData((prev) => ({ ...prev, avatar_image: reader.result as string })); + }; + reader.readAsDataURL(e.target.files[0]); + } + }; + + const handleSetupTelegram = async () => { + setTelegramSetupStatus('loading'); + setTelegramError(null); + setTelegramBotInfo(null); + + try { + // Validate the token format + if (!formData.telegram_bot_token || !formData.telegram_bot_token.includes(':')) { + throw new Error(t('profile.invalidTelegramToken')); + } + + // Send setup request to the server + const response = await fetch('/api/telegram/setup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: formData.telegram_bot_token }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.telegramSetupFailed')); + } + + const data = await response.json(); + setTelegramSetupStatus('success'); + setSuccess(t('profile.telegramSetupSuccess')); + + // Save bot info for display + if (data.bot) { + setTelegramBotInfo(data.bot); + setIsPolling(true); + + // Explicitly verify polling is started + if (!data.bot.polling_status?.running) { + console.log('Polling not started automatically during setup. Starting manually...'); + // Small delay to ensure the server has registered the token + setTimeout(() => { + handleStartPolling(); + }, 1000); + } + } + + // Format the URL to start the bot chat + const botUsername = data.bot?.username || formData.telegram_bot_token.split(':')[0]; + + // Open the Telegram bot chat in a new window + window.open(`https://t.me/${botUsername}`, '_blank'); + + } catch (error) { + console.error('Telegram setup error:', error); + setTelegramSetupStatus('error'); + setTelegramError((error as Error).message); + } + }; + + const handleStartPolling = async () => { + try { + const response = await fetch('/api/telegram/start-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.startPollingFailed')); + } + + const data = await response.json(); + setIsPolling(true); + showSuccessToast(t('profile.pollingStarted')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Start polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; + + const handleStopPolling = async () => { + try { + const response = await fetch('/api/telegram/stop-polling', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || t('profile.stopPollingFailed')); + } + + const data = await response.json(); + setIsPolling(false); + showSuccessToast(t('profile.pollingStopped')); + + // Update bot info if available + if (telegramBotInfo) { + setTelegramBotInfo({ + ...telegramBotInfo, + polling_status: data.status + }); + } + } catch (error) { + console.error('Stop polling error:', error); + showErrorToast(t('profile.pollingError')); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + try { + const response = await fetch('/api/profile', { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update profile.'); + } + + const updatedProfile: Profile = await response.json(); + setProfile(updatedProfile); + + // Make sure to update language if it was changed + if (updatedProfile.language !== i18n.language) { + console.log('Updating language after form submission:', updatedProfile.language); + await i18n.changeLanguage(updatedProfile.language); + } + + setSuccess(t('profile.successMessage')); + } catch (err) { + setError((err as Error).message); + } + }; + + if (loading) { + return ( +
      +
      + {t('common.loading')} +
      +
      + ); + } + + if (error) { + return ( +
      +
      {error}
      +
      + ); + } + + return ( +
      +

      + {t('profile.title')} +

      + + {/* Debug information */} + {process.env.NODE_ENV === 'development' && ( +
      +

      Current language: {i18n.language}

      +

      Initialized: {i18n.isInitialized ? 'Yes' : 'No'}

      +

      Available languages: {i18n.languages?.join(', ')}

      +
      + )} + + {success &&
      {success}
      } + {error &&
      {error}
      } + +
      + {/* Appearance Selection */} +
      + + +
      + + {/* Language Selection */} +
      + + +

      + {t('profile.languageChangedNote', 'Language changes are applied immediately')} +

      + {isChangingLanguage && ( +
      + {t('profile.languageChanging', 'Changing language...')} +
      + )} +
      + + {/* Timezone Selection */} +
      + + +
      + + {/* Telegram Integration */} +
      +

      + {t('profile.telegramIntegration', 'Telegram Integration')} +

      + +
      + +

      + {t('profile.telegramDescription', 'Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.')} +

      +
      + +
      + + +

      + {t('profile.telegramTokenDescription', 'Create a bot with @BotFather on Telegram and paste the token here.')} +

      +
      + + {profile?.telegram_chat_id && ( +
      +

      + {t('profile.telegramConnected', 'Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.')} +

      +
      + )} + + {telegramError && ( +
      +

      {telegramError}

      +
      + )} + + {telegramBotInfo && ( +
      +

      + {t('profile.botConfigured', 'Bot configured successfully!')} +

      + +
      +

      + {t('profile.botUsername', 'Bot Username:')} + @{telegramBotInfo.username} +

      + +
      +

      {t('profile.pollingStatus', 'Polling Status:')}

      + +
      +
      + {isPolling ? t('profile.pollingActive') : t('profile.pollingInactive')} +
      + +

      + {t('profile.pollingNote', 'Polling periodically checks for new messages from Telegram and adds them to your inbox.')} +

      + +
      + {isPolling ? ( + + ) : ( + + )} + + {t('profile.openTelegram', 'Open in Telegram')} + + + {/* Test button for development */} + +
      +
      +
      +
      + )} + + +
      + + {/* Avatar Image Upload */} + {/*
      + + + {formData.avatar_image && ( + Avatar Preview + )} +
      */} + + {/* Save Button */} +
      + +
      +
      +
      + ); +}; + +export default ProfileSettings; diff --git a/app/frontend/components/Project/ProjectItem.tsx b/app/frontend/components/Project/ProjectItem.tsx index 6a9ac53..c729885 100644 --- a/app/frontend/components/Project/ProjectItem.tsx +++ b/app/frontend/components/Project/ProjectItem.tsx @@ -2,6 +2,7 @@ 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; @@ -37,6 +38,7 @@ const ProjectItem: React.FC = ({ setProjectToDelete, setIsConfirmDialogOpen, }) => { + const { t } = useTranslation(); return (
      = ({ className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg" style={{ height: "140px" }} > - + {getProjectInitials(project.name)}
      = ({ activeDropdown === project.id ? null : project.id ?? null ) } + aria-label={t("projectItem.toggleDropdownMenu")} > @@ -96,7 +102,7 @@ const ProjectItem: React.FC = ({ 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" > - Edit + {t("projectItem.edit")}
      )} @@ -125,7 +131,7 @@ const ProjectItem: React.FC = ({ >
      - {getCompletionPercentage(project?.id)}% + {t("projectItem.completionPercentage", { percentage: getCompletionPercentage(project?.id) })} diff --git a/app/frontend/components/Project/ProjectModal.tsx b/app/frontend/components/Project/ProjectModal.tsx index 7f0355f..cb76dd9 100644 --- a/app/frontend/components/Project/ProjectModal.tsx +++ b/app/frontend/components/Project/ProjectModal.tsx @@ -9,6 +9,7 @@ import { PriorityType } from "../../entities/Task"; import Switch from "../Shared/Switch"; import { useStore } from "../../store/useStore"; import { fetchTags } from "../../utils/tagsService"; +import { useTranslation } from "react-i18next"; interface ProjectModalProps { isOpen: boolean; @@ -51,6 +52,7 @@ const ProjectModal: React.FC = ({ const [showConfirmDialog, setShowConfirmDialog] = useState(false); const { showSuccessToast } = useToast(); + const { t } = useTranslation(); useEffect(() => { if (project) { @@ -212,13 +214,13 @@ const ProjectModal: React.FC = ({ onChange={handleChange} required className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2" - placeholder="Enter project name" + placeholder={t('project.name', 'Enter project name')} />
      = ({
      = ({
      = ({
      { 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" > - - - + + +
      @@ -256,7 +258,7 @@ useEffect(() => { htmlFor="areaFilter" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" > - Area + {t('common.area')} setSearchQuery(e.target.value)} className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" @@ -299,7 +301,7 @@ useEffect(() => { > {Object.keys(groupedProjects).length === 0 ? (
      - No projects found. + {t('projects.noProjectsFound')}
      ) : ( Object.keys(groupedProjects).map((areaName) => ( @@ -347,8 +349,8 @@ useEffect(() => { {isConfirmDialogOpen && ( setIsConfirmDialogOpen(false)} /> diff --git a/app/frontend/components/Shared/ConfirmDialog.tsx b/app/frontend/components/Shared/ConfirmDialog.tsx index cf44c56..83fab8f 100644 --- a/app/frontend/components/Shared/ConfirmDialog.tsx +++ b/app/frontend/components/Shared/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; interface ConfirmDialogProps { title: string; @@ -8,23 +9,25 @@ interface ConfirmDialogProps { } const ConfirmDialog: React.FC = ({ title, message, onConfirm, onCancel }) => { + const { t } = useTranslation(); + return (
      -
      +

      {title}

      -

      {message}

      +

      {message}

      diff --git a/app/frontend/components/Shared/LoadingScreen.tsx b/app/frontend/components/Shared/LoadingScreen.tsx new file mode 100644 index 0000000..a82e13e --- /dev/null +++ b/app/frontend/components/Shared/LoadingScreen.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +const LoadingScreen: React.FC = () => ( +
      +
      Loading application... Please wait.
      +
      +); + +export default LoadingScreen; + diff --git a/app/frontend/components/Shared/PriorityDropdown.tsx b/app/frontend/components/Shared/PriorityDropdown.tsx index 4638a31..f18ac00 100644 --- a/app/frontend/components/Shared/PriorityDropdown.tsx +++ b/app/frontend/components/Shared/PriorityDropdown.tsx @@ -1,19 +1,21 @@ import React, { useState, useRef, useEffect } from 'react'; import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline'; import { PriorityType } from '../../entities/Task'; +import { useTranslation } from 'react-i18next'; interface PriorityDropdownProps { value: PriorityType; onChange: (value: PriorityType) => void; } -const priorities = [ - { value: 'low', label: 'Low', icon: }, - { value: 'medium', label: 'Medium', icon: }, - { value: 'high', label: 'High', icon: } -]; - const PriorityDropdown: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + const priorities = [ + { value: 'low', label: t('priority.low', 'Low'), icon: }, + { value: 'medium', label: t('priority.medium', 'Medium'), icon: }, + { value: 'high', label: t('priority.high', 'High'), icon: } + ]; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -55,7 +57,7 @@ const PriorityDropdown: React.FC = ({ value, onChange }) > {selectedPriority ? selectedPriority.icon : ''} - {selectedPriority ? selectedPriority.label : 'Select Priority'} + {selectedPriority ? selectedPriority.label : t('forms.priority', 'Select Priority')} diff --git a/app/frontend/components/Shared/StatusDropdown.tsx b/app/frontend/components/Shared/StatusDropdown.tsx index 27beee7..fdc8c8f 100644 --- a/app/frontend/components/Shared/StatusDropdown.tsx +++ b/app/frontend/components/Shared/StatusDropdown.tsx @@ -1,20 +1,22 @@ import React, { useState, useRef, useEffect } from 'react'; import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; import { StatusType } from '../../entities/Task'; +import { useTranslation } from 'react-i18next'; interface StatusDropdownProps { value: StatusType; onChange: (value: StatusType) => void; } -const statuses = [ - { value: 'not_started', label: 'Not Started', icon: }, - { value: 'in_progress', label: 'In Progress', icon: }, - { value: 'done', label: 'Done', icon: }, - { value: 'archived', label: 'Archived', icon: }, -]; - const StatusDropdown: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + const statuses = [ + { value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: }, + { value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: }, + { value: 'done', label: t('status.done', 'Done'), icon: }, + { value: 'archived', label: t('status.archived', 'Archived'), icon: }, + ]; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); diff --git a/app/frontend/components/Sidebar.tsx b/app/frontend/components/Sidebar.tsx index 9504aa0..68a54e5 100644 --- a/app/frontend/components/Sidebar.tsx +++ b/app/frontend/components/Sidebar.tsx @@ -17,7 +17,7 @@ interface SidebarProps { currentUser: { email: string }; isDarkMode: boolean; toggleDarkMode: () => void; - openTaskModal: () => void; + openTaskModal: (type?: 'simplified' | 'full') => void; openProjectModal: () => void; openNoteModal: (note: Note | null) => void; openAreaModal: (area: Area | null) => void; @@ -71,7 +71,7 @@ const Sidebar: React.FC = ({
      {/* Sidebar Contents */} openTaskModal(type || 'full')} openProjectModal={openProjectModal} openNoteModal={openNoteModal} openAreaModal={openAreaModal} diff --git a/app/frontend/components/Sidebar/CreateNewDropdownButton.tsx b/app/frontend/components/Sidebar/CreateNewDropdownButton.tsx index 696b946..ee8dbd0 100644 --- a/app/frontend/components/Sidebar/CreateNewDropdownButton.tsx +++ b/app/frontend/components/Sidebar/CreateNewDropdownButton.tsx @@ -7,11 +7,12 @@ import { BookOpenIcon, Squares2X2Icon, } from '@heroicons/react/24/outline'; +import { useTranslation } from 'react-i18next'; import { Note } from '../../entities/Note'; import { Area } from '../../entities/Area'; interface CreateNewDropdownButtonProps { - openTaskModal: () => void; + openTaskModal: (type?: 'simplified' | 'full') => void; openProjectModal: () => void; openNoteModal: (note: Note | null) => void; openAreaModal: (area: Area | null) => void; @@ -23,6 +24,7 @@ const CreateNewDropdownButton: React.FC = ({ openNoteModal, openAreaModal, }) => { + const { t } = useTranslation(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const toggleDropdown = () => { @@ -32,7 +34,7 @@ const CreateNewDropdownButton: React.FC = ({ const handleDropdownSelect = (type: string) => { switch (type) { case 'Task': - openTaskModal(); + openTaskModal('full'); break; case 'Project': openProjectModal(); @@ -50,10 +52,10 @@ const CreateNewDropdownButton: React.FC = ({ }; const dropdownItems = [ - { label: 'Task', icon: }, - { label: 'Project', icon: }, - { label: 'Note', icon: }, - { label: 'Area', icon: }, + { label: 'Task', translationKey: 'dropdown.task', icon: }, + { label: 'Project', translationKey: 'dropdown.project', icon: }, + { label: 'Note', translationKey: 'dropdown.note', icon: }, + { label: 'Area', translationKey: 'dropdown.area', icon: }, ]; return ( @@ -69,7 +71,7 @@ const CreateNewDropdownButton: React.FC = ({ className="w-5 h-5 mr-2 text-gray-500 dark:text-gray-400" aria-hidden="true" /> - Create New + {t('dropdown.createNew')} = ({ aria-orientation="vertical" aria-labelledby="options-menu" > - {dropdownItems.map(({ label, icon }) => ( + {dropdownItems.map(({ label, translationKey, icon }) => (
    • = ({ role="menuitem" > {icon} - {label} + {t(translationKey)}
    • ))}
    diff --git a/app/frontend/components/Sidebar/SidebarAreas.tsx b/app/frontend/components/Sidebar/SidebarAreas.tsx index 74f52ec..58a27f0 100644 --- a/app/frontend/components/Sidebar/SidebarAreas.tsx +++ b/app/frontend/components/Sidebar/SidebarAreas.tsx @@ -2,6 +2,7 @@ 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; @@ -16,6 +17,7 @@ const SidebarAreas: React.FC = ({ location, openAreaModal, }) => { + const { t } = useTranslation(); const isActiveArea = (path: string) => { return location.pathname === path ? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white" @@ -40,7 +42,7 @@ const SidebarAreas: React.FC = ({ > - AREAS + {t('sidebar.areas')} diff --git a/app/frontend/components/Sidebar/SidebarNav.tsx b/app/frontend/components/Sidebar/SidebarNav.tsx index d961953..b3f6b3a 100644 --- a/app/frontend/components/Sidebar/SidebarNav.tsx +++ b/app/frontend/components/Sidebar/SidebarNav.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { Location } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { CalendarDaysIcon, CalendarIcon, ArrowRightCircleIcon, InboxIcon, - ClockIcon, - PauseCircleIcon, CheckCircleIcon, ListBulletIcon, } from '@heroicons/react/24/solid'; @@ -17,19 +16,30 @@ interface SidebarNavProps { isDarkMode: boolean; } -const navLinks = [ - { path: '/today', title: 'Today', icon: , query: 'type=today' }, - { path: '/tasks?type=upcoming', title: 'Upcoming', icon: , query: 'type=upcoming' }, - { path: '/tasks?type=next', title: 'Next Actions', icon: , query: 'type=next' }, - { path: '/tasks?type=inbox', title: 'Inbox', icon: , query: 'type=inbox' }, - // { path: '/tasks?type=someday', title: 'Someday', icon: , query: 'type=someday' }, - // { path: '/tasks?type=waiting', title: 'Waiting for', icon: , query: 'type=waiting' }, - { path: '/tasks?status=done', title: 'Completed', icon: , query: 'status=done' }, - { path: '/tasks', title: 'All Tasks', icon: }, -]; - const SidebarNav: React.FC = ({ handleNavClick, location }) => { + const { t } = useTranslation(); + + const navLinks = [ + { path: '/inbox', title: t('sidebar.inbox', 'Inbox'), icon: }, + { path: '/today', title: t('sidebar.today', 'Today'), icon: , query: 'type=today' }, + { path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: , query: 'type=upcoming' }, + { path: '/tasks?type=next', title: t('sidebar.nextActions', 'Next Actions'), icon: , query: 'type=next' }, + // { path: '/tasks?type=someday', title: t('sidebar.someday', 'Someday'), icon: , query: 'type=someday' }, + // { path: '/tasks?type=waiting', title: t('sidebar.waitingFor', 'Waiting for'), icon: , query: 'type=waiting' }, + { path: '/tasks?status=done', title: t('sidebar.completed', 'Completed'), icon: , query: 'status=done' }, + { path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: }, + ]; + const isActive = (path: string, query?: string) => { + // Handle special case for paths without query parameters + if (path === '/inbox' || path === '/today') { + const isPathMatch = location.pathname === path; + return isPathMatch + ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' + : 'text-gray-700 dark:text-gray-300'; + } + + // Regular case for /tasks with query params const isPathMatch = location.pathname === '/tasks'; const isQueryMatch = query ? location.search.includes(query) : location.search === ''; return isPathMatch && isQueryMatch @@ -40,21 +50,26 @@ const SidebarNav: React.FC = ({ handleNavClick, location }) => return (
      {navLinks.map((link) => ( -
    • - -
    • + +
    • + +
    • + {link.path === '/inbox' && ( +
    • + )} + ))}
    ); }; -export default SidebarNav; +export default SidebarNav; \ No newline at end of file diff --git a/app/frontend/components/Sidebar/SidebarNotes.tsx b/app/frontend/components/Sidebar/SidebarNotes.tsx index d86eedd..effe4f8 100644 --- a/app/frontend/components/Sidebar/SidebarNotes.tsx +++ b/app/frontend/components/Sidebar/SidebarNotes.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Location } from 'react-router-dom'; import { BookOpenIcon, PlusCircleIcon } from '@heroicons/react/24/outline'; import { Note } from '../../entities/Note'; +import { useTranslation } from 'react-i18next'; interface SidebarNotesProps { handleNavClick: (path: string, title: string, icon: JSX.Element) => void; @@ -16,6 +17,7 @@ const SidebarNotes: React.FC = ({ location, openNoteModal, }) => { + const { t } = useTranslation(); const isActiveNote = (path: string) => { return location.pathname === path ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' @@ -33,7 +35,7 @@ const SidebarNotes: React.FC = ({ > - NOTES + {t('sidebar.notes')} diff --git a/app/frontend/components/Tag/TagDetails.tsx b/app/frontend/components/Tag/TagDetails.tsx index b512b8d..a5a2dcc 100644 --- a/app/frontend/components/Tag/TagDetails.tsx +++ b/app/frontend/components/Tag/TagDetails.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; interface Tag { id: number; @@ -8,6 +9,7 @@ interface Tag { } const TagDetails: React.FC = () => { + const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const [tag, setTag] = useState(null); const [loading, setLoading] = useState(true); @@ -25,7 +27,7 @@ const TagDetails: React.FC = () => { setError(data.error || 'Failed to fetch tag.'); } } catch (err) { - setError('Error fetching tag.'); + setError(t('tags.error')); } finally { setLoading(false); } @@ -40,7 +42,7 @@ const TagDetails: React.FC = () => { }; if (loading) { - return
    Loading tag details...
    ; + return
    {t('tags.loading')}
    ; } if (error) { @@ -48,17 +50,17 @@ const TagDetails: React.FC = () => { } if (!tag) { - return
    Tag not found.
    ; + return
    {t('tags.notFound')}
    ; } return (
    -

    Tag Details

    +

    {t('tags.details')}

    - Name: {tag.name} + {t('tags.name')}: {tag.name}

    - Status: {tag.active ? 'Active' : 'Inactive'} + {t('tags.status')}: {tag.active ? t('tags.active') : t('tags.inactive')}

    {/* "View tasks with this tag" button */} @@ -66,7 +68,7 @@ const TagDetails: React.FC = () => { onClick={handleViewTasks} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" > - View tasks with this tag + {t('tags.viewTasksWithTag')}
    ); diff --git a/app/frontend/components/Tag/TagInput.tsx b/app/frontend/components/Tag/TagInput.tsx index ceb6a76..e0bf38e 100644 --- a/app/frontend/components/Tag/TagInput.tsx +++ b/app/frontend/components/Tag/TagInput.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Tag } from '../../entities/Tag'; +import { useTranslation } from 'react-i18next'; interface TagInputProps { initialTags: string[]; @@ -8,6 +9,7 @@ interface TagInputProps { } const TagInput: React.FC = ({ initialTags, onTagsChange, availableTags }) => { + const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); const [tags, setTags] = useState(initialTags || []); const [filteredTags, setFilteredTags] = useState([]); @@ -17,6 +19,25 @@ const TagInput: React.FC = ({ initialTags, onTagsChange, availabl const containerRef = useRef(null); const dropdownRef = useRef(null); + // Update internal tags state when initialTags prop changes + useEffect(() => { + console.log("TagInput received initialTags:", initialTags); + + // Set the tags state with the initial tags + if (initialTags && initialTags.length > 0) { + // Simply set our internal state to match the initialTags + setTags(initialTags); + console.log("Set tags to match initialTags:", initialTags); + } + }, [initialTags]); + + // Clean up effect to notify parent when our tags state changes + useEffect(() => { + // Notify parent of current state + console.log("TagInput internal tags state changed to:", tags); + onTagsChange(tags); + }, [tags, onTagsChange]); + useEffect(() => { const handler = setTimeout(() => { if (inputValue.trim() === '') { @@ -118,24 +139,28 @@ const TagInput: React.FC = ({ initialTags, onTagsChange, availabl
    - {tags.map((tag, index) => ( - - {tag} - - - ))} + {tag} + + + )) + ) : ( + + )} = ({ initialTags, onTagsChange, availabl value={inputValue} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder="Type to add a tag" + placeholder={t('tags.typeToAdd')} className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100" onFocus={() => { if (filteredTags.length > 0) setIsDropdownOpen(true); diff --git a/app/frontend/components/Tag/TagModal.tsx b/app/frontend/components/Tag/TagModal.tsx index 3804f1a..ca1af0b 100644 --- a/app/frontend/components/Tag/TagModal.tsx +++ b/app/frontend/components/Tag/TagModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Tag } from '../../entities/Tag'; import { XMarkIcon } from '@heroicons/react/24/outline'; import { useToast } from '../Shared/ToastContext'; +import { useTranslation } from 'react-i18next'; interface TagModalProps { isOpen: boolean; @@ -26,6 +27,7 @@ const TagModal: React.FC = ({ const [isClosing, setIsClosing] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const { showSuccessToast, showErrorToast } = useToast(); + const { t } = useTranslation(); useEffect(() => { if (tag) { @@ -79,7 +81,7 @@ const TagModal: React.FC = ({ const handleSubmit = async () => { if (!formData.name.trim()) { - showErrorToast('Tag name is required.'); + showErrorToast(t('errors.tagNameRequired', 'Tag name is required.')); return; } @@ -87,14 +89,14 @@ const TagModal: React.FC = ({ try { if (tag) { - showSuccessToast('Tag updated successfully!'); + showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!')); } else { - showSuccessToast('Tag created successfully!'); + showSuccessToast(t('success.tagCreated', 'Tag created successfully!')); } onSave(formData); handleClose(); } catch (err) { - showErrorToast('Failed to save tag.'); + showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.')); } finally { setIsSubmitting(false); } @@ -139,7 +141,7 @@ const TagModal: React.FC = ({ onChange={handleChange} required className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2" - placeholder="Enter tag name" + placeholder={t('forms.tagNamePlaceholder', 'Enter tag name')} />
    @@ -151,7 +153,7 @@ const TagModal: React.FC = ({ onClick={handleClose} className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out" > - Cancel + {t('common.cancel', 'Cancel')} diff --git a/app/frontend/components/Task/NewTask.tsx b/app/frontend/components/Task/NewTask.tsx index 9fe7581..28844c7 100644 --- a/app/frontend/components/Task/NewTask.tsx +++ b/app/frontend/components/Task/NewTask.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useToast } from '../../components/Shared/ToastContext'; +import { useTranslation } from 'react-i18next'; import { PlusCircleIcon } from '@heroicons/react/24/outline'; interface NewTaskProps { @@ -8,7 +9,8 @@ interface NewTaskProps { const NewTask: React.FC = ({ onTaskCreate }) => { const [taskName, setTaskName] = useState(''); - const { showSuccessToast, showErrorToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); + const { t } = useTranslation(); const handleInputChange = (event: React.ChangeEvent) => { setTaskName(event.target.value); @@ -19,10 +21,10 @@ const NewTask: React.FC = ({ onTaskCreate }) => { try { await onTaskCreate(taskName.trim()); setTaskName(''); - showSuccessToast('Task created successfully!'); + showSuccessToast(t('success.taskCreated', 'Task created successfully!')); } catch (error) { console.error('Error creating task:', error); - showErrorToast('Failed to create task.'); + showErrorToast(t('errors.taskCreate', 'Failed to create task.')); } } }; @@ -38,7 +40,7 @@ const NewTask: React.FC = ({ 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="Add New Task" + placeholder={t('tasks.addNewTask', 'Προσθήκη Νέας Εργασίας')} /> ); diff --git a/app/frontend/components/Task/SimplifiedTaskModal.tsx b/app/frontend/components/Task/SimplifiedTaskModal.tsx new file mode 100644 index 0000000..ac0823f --- /dev/null +++ b/app/frontend/components/Task/SimplifiedTaskModal.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Task } from "../../entities/Task"; +import { InboxItem } from "../../entities/InboxItem"; +import { useToast } from "../Shared/ToastContext"; +import { useTranslation } from "react-i18next"; +import { createInboxItemWithStore } from "../../utils/inboxService"; + +interface SimplifiedTaskModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (task: Task) => void; + initialText?: string; + editMode?: boolean; + onEdit?: (text: string) => Promise; +} + +const SimplifiedTaskModal: React.FC = ({ + isOpen, + onClose, + onSave, + initialText = "", + editMode = false, + onEdit, +}) => { + const { t } = useTranslation(); + const [inputText, setInputText] = useState(initialText); + const modalRef = useRef(null); + const [isClosing, setIsClosing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const { showSuccessToast, showErrorToast } = useToast(); + const nameInputRef = useRef(null); + const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox'); + + useEffect(() => { + if (isOpen && nameInputRef.current) { + nameInputRef.current.focus(); + } + }, [isOpen]); + + const handleChange = (e: React.ChangeEvent) => { + setInputText(e.target.value); + }; + + const handleSubmit = useCallback(async () => { + if (!inputText.trim() || isSaving) return; + + setIsSaving(true); + + try { + if (editMode && onEdit) { + await onEdit(inputText.trim()); + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 300); + return; // Exit early to prevent creating duplicates + } + + if (saveMode === 'task') { + const newTask: Task = { + name: inputText.trim(), + status: "not_started", + }; + + onSave(newTask); + showSuccessToast(t('task.createSuccess')); + setInputText(''); + } else { + try { + const newItem = await createInboxItemWithStore(inputText.trim()); + + showSuccessToast(t('inbox.itemAdded')); + + handleClose(); + } catch (error) { + console.error('Failed to create inbox item:', error); + showErrorToast(t('inbox.addError')); + setIsSaving(false); + } + } + } catch (error) { + console.error('Failed to save:', error); + if (editMode) { + showErrorToast(t('inbox.updateError')); + } else { + showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError')); + } + setIsSaving(false); + } + }, [inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose]); + + const handleClose = useCallback(() => { + setIsClosing(true); + setTimeout(() => { + onClose(); + if (!editMode) { + setInputText(""); + setSaveMode('inbox'); + } + setIsClosing(false); + }, 300); + }, [onClose, editMode]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + handleClose(); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleClose(); + } + }; + if (isOpen) { + document.addEventListener("keydown", handleKeyDown); + } + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, handleClose]); // Only depend on isOpen and handleClose + + if (!isOpen) return null; + + return ( +
    +
    +
    + { + if (e.key === 'Enter' && !e.shiftKey && !isSaving) { + e.preventDefault(); + handleSubmit(); + } + }} + /> + +
    +
    +
    + ); +}; + +export default SimplifiedTaskModal; + diff --git a/app/frontend/components/Task/TaskActions.tsx b/app/frontend/components/Task/TaskActions.tsx index 8d76a4f..d693d82 100644 --- a/app/frontend/components/Task/TaskActions.tsx +++ b/app/frontend/components/Task/TaskActions.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; interface TaskActionsProps { taskId: number | undefined; @@ -8,6 +9,8 @@ interface TaskActionsProps { } const TaskActions: React.FC = ({ taskId, onDelete, onSave, onCancel }) => { + const { t } = useTranslation(); + return (
    {taskId && ( @@ -16,7 +19,7 @@ const TaskActions: React.FC = ({ taskId, onDelete, onSave, onC onClick={onDelete} className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600" > - Delete + {t('common.delete', 'Delete')} )}
    ); diff --git a/app/frontend/components/Task/TaskDueDate.tsx b/app/frontend/components/Task/TaskDueDate.tsx index b229f5f..2209186 100644 --- a/app/frontend/components/Task/TaskDueDate.tsx +++ b/app/frontend/components/Task/TaskDueDate.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; interface TaskDueDateProps { dueDate: string; @@ -6,6 +7,7 @@ interface TaskDueDateProps { } const TaskDueDate: React.FC = ({ 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]; @@ -21,9 +23,9 @@ const TaskDueDate: React.FC = ({ dueDate, className }) => { 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 'TODAY'; - if (dueDate === tomorrow) return 'TOMORROW'; - if (dueDate === yesterday) return 'YESTERDAY'; + if (dueDate === today) return t('dateIndicators.today', 'TODAY'); + if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW'); + if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY'); return new Date(dueDate).toLocaleDateString(undefined, { year: 'numeric', diff --git a/app/frontend/components/Task/TaskModal.tsx b/app/frontend/components/Task/TaskModal.tsx index 9955c8f..5839d40 100644 --- a/app/frontend/components/Task/TaskModal.tsx +++ b/app/frontend/components/Task/TaskModal.tsx @@ -9,6 +9,7 @@ import TagInput from "../Tag/TagInput"; import { Project } from "../../entities/Project"; import { useStore } from "../../store/useStore"; import { fetchTags } from '../../utils/tagsService'; +import { useTranslation } from "react-i18next"; interface TaskModalProps { isOpen: boolean; @@ -41,6 +42,7 @@ const TaskModal: React.FC = ({ const { showSuccessToast, showErrorToast } = useToast(); const { tagsStore } = useStore(); const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore; + const { t } = useTranslation(); useEffect(() => { setFormData(task); @@ -207,12 +209,12 @@ const TaskModal: React.FC = ({ onChange={handleChange} required className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2" - placeholder="Add Task Name" + placeholder={t('forms.task.namePlaceholder', 'Add Task Name')} />
    = ({
    = ({ )) ) : (
    - No matching projects + {t('forms.task.noMatchingProjects', 'No matching projects')}
    )} {newProjectName && ( @@ -259,8 +261,8 @@ const TaskModal: React.FC = ({ className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600" > {isCreatingProject - ? "Creating..." - : `+ Create "${newProjectName}"`} + ? t('forms.task.creatingProject', 'Creating...') + : t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`} )}
    @@ -269,7 +271,7 @@ const TaskModal: React.FC = ({
    = ({
    = ({
    = ({
    @@ -332,8 +334,8 @@ const TaskModal: React.FC = ({
    {showConfirmDialog && ( setShowConfirmDialog(false)} /> diff --git a/app/frontend/components/Task/TaskPriorityIcon.tsx b/app/frontend/components/Task/TaskPriorityIcon.tsx index eb5b6a6..86eca5b 100644 --- a/app/frontend/components/Task/TaskPriorityIcon.tsx +++ b/app/frontend/components/Task/TaskPriorityIcon.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { CheckCircleIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; interface TaskPriorityIconProps { priority: string | undefined; @@ -7,6 +8,7 @@ interface TaskPriorityIconProps { } const TaskPriorityIcon: React.FC = ({ priority, status }) => { + const { t } = useTranslation(); const getIconColor = () => { if (status === 'done') return 'text-green-500'; switch (priority) { diff --git a/app/frontend/components/Task/TaskStatusBadge.tsx b/app/frontend/components/Task/TaskStatusBadge.tsx index 3e43921..2e46003 100644 --- a/app/frontend/components/Task/TaskStatusBadge.tsx +++ b/app/frontend/components/Task/TaskStatusBadge.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; interface TaskStatusBadgeProps { status: string; @@ -7,28 +8,29 @@ interface TaskStatusBadgeProps { } const TaskStatusBadge: React.FC = ({ status, className }) => { + const { t } = useTranslation(); let statusIcon, statusLabel; switch (status) { case 'not_started': statusIcon = ; - statusLabel = 'Not Started'; + statusLabel = t('status.notStarted', 'Not Started'); break; case 'in_progress': statusIcon = ; - statusLabel = 'In Progress'; + statusLabel = t('status.inProgress', 'In Progress'); break; case 'done': statusIcon = ; - statusLabel = 'Done'; + statusLabel = t('status.done', 'Done'); break; case 'archived': statusIcon = ; - statusLabel = 'Archived'; + statusLabel = t('status.archived', 'Archived'); break; default: statusIcon = ; - statusLabel = 'Unknown'; + statusLabel = t('status.unknown', 'Unknown'); } return ( diff --git a/app/frontend/components/Task/TasksToday.tsx b/app/frontend/components/Task/TasksToday.tsx index c7c78b7..e58bb96 100644 --- a/app/frontend/components/Task/TasksToday.tsx +++ b/app/frontend/components/Task/TasksToday.tsx @@ -1,10 +1,17 @@ -import React, { useEffect } from "react"; +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 { Link } from "react-router-dom"; import { ClipboardDocumentListIcon, ArrowPathIcon, CalendarDaysIcon, ClockIcon, + InboxIcon, + FolderIcon, + ArchiveBoxIcon, } from "@heroicons/react/24/outline"; import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService"; import { fetchProjects } from "../../utils/projectsService"; @@ -13,21 +20,37 @@ import { useStore } from "../../store/useStore"; import TaskList from "./TaskList"; import { Metrics } from "../../entities/Metrics"; +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; + } +}; const TasksToday: React.FC = () => { - const { - tasks, - setTasks, - setLoading: setTasksLoading, - setError: setTasksError, - } = useStore((state) => state.tasksStore); - const { - projects, - setProjects, - setLoading: setProjectsLoading, - setError: setProjectsError, - } = useStore((state) => state.projectsStore); - - const [metrics, setMetrics] = React.useState({ + const { t } = useTranslation(); + + // Don't use multiple separate useStore calls - combine them into one + const store = useStore(); + + // Use local state for data instead of directly using store state + // This prevents unnecessary re-renders from store updates + const [localTasks, setLocalTasks] = useState([]); + const [localProjects, setLocalProjects] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + // Metrics from the API + const [metrics, setMetrics] = useState({ total_open_tasks: 0, tasks_pending_over_month: 0, tasks_in_progress_count: 0, @@ -36,156 +59,288 @@ const TasksToday: React.FC = () => { suggested_tasks: [], }); + // Track mounting state to prevent state updates after unmount + const isMounted = React.useRef(false); + + // Load data once on component mount useEffect(() => { + isMounted.current = true; + + // Only fetch data once on mount const loadData = async () => { + if (!isMounted.current) return; + + setIsLoading(true); + setIsError(false); + try { - // setProjectsLoading(true); + // Load projects first const projectsData = await fetchProjects(); - setProjects(projectsData); - - const { tasks: fetchedTasks, metrics } = await fetchTasks("?type=today"); - setTasks(fetchedTasks); - setMetrics(metrics); + if (isMounted.current) { + setLocalProjects(projectsData); + // Also update the store + store.projectsStore.setProjects(projectsData); + } } catch (error) { - console.error("Error loading data:", error); - setProjectsError(true); - setTasksError(true); + console.error("Failed to fetch projects:", error); + if (isMounted.current) { + setIsError(true); + } + } + + try { + // Load tasks with metrics + const { tasks: fetchedTasks, metrics: fetchedMetrics } = await fetchTasks("?type=today"); + if (isMounted.current) { + setLocalTasks(fetchedTasks); + setMetrics(fetchedMetrics); + // Also update the store + store.tasksStore.setTasks(fetchedTasks); + } + } catch (error) { + console.error("Failed to fetch tasks:", error); + if (isMounted.current) { + setIsError(true); + } } finally { - // setProjectsLoading(false); - // setTasksLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } }; loadData(); - }, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]); + + // Cleanup function to prevent state updates after unmount + return () => { + isMounted.current = false; + }; + }, []); // Empty dependency array - only run once on mount - const handleTaskUpdate = async (updatedTask: Task): Promise => { - if (!updatedTask.id) return; + // Memoize task handlers to prevent recreating functions on each render + const handleTaskUpdate = useCallback(async (updatedTask: Task): Promise => { + if (!updatedTask.id || !isMounted.current) return; + setIsLoading(true); try { - setTasksLoading(true); await updateTask(updatedTask.id, updatedTask); + // Refetch data to ensure consistency const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); - setTasks(updatedTasks); - setMetrics(metrics); + + if (isMounted.current) { + setLocalTasks(updatedTasks); + setMetrics(metrics); + // Update store + store.tasksStore.setTasks(updatedTasks); + } } catch (error) { console.error("Error updating task:", error); - setTasksError(true); + if (isMounted.current) { + setIsError(true); + } } finally { - setTasksLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } - }; + }, [store.tasksStore]); - const handleTaskDelete = async (taskId: number): Promise => { + const handleTaskDelete = useCallback(async (taskId: number): Promise => { + if (!isMounted.current) return; + + setIsLoading(true); try { - setTasksLoading(true); await deleteTask(taskId); + // Refetch data to ensure consistency const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); - setTasks(updatedTasks); - setMetrics(metrics); + + if (isMounted.current) { + setLocalTasks(updatedTasks); + setMetrics(metrics); + // Update store + store.tasksStore.setTasks(updatedTasks); + } } catch (error) { console.error("Error deleting task:", error); - setTasksError(true); + if (isMounted.current) { + setIsError(true); + } } finally { - setTasksLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } - }; + }, [store.tasksStore]); - const todayDate = format(new Date(), "yyyy-MM-dd"); + // Get inbox items count from store for the notification + const inboxItemsCount = store.inboxStore.inboxItems.length; + + // Show loading state + if (isLoading && localTasks.length === 0) { + return ( +
    +

    {t('common.loading', 'Loading...')}

    +
    + ); + } + + // Show error state + if (isError && localTasks.length === 0) { + return ( +
    +

    {t('errors.somethingWentWrong', 'Something went wrong')}

    +
    + ); + } return (

    - Today + {t('tasks.today')}

    - {format(new Date(), "EEEE, MMMM d, yyyy")} + {format(new Date(), "PPP", { locale: getLocale(i18n.language) })}
    -
    -
    - -
    -

    Backlog

    -

    - {metrics.total_open_tasks} -

    +
    + {/* Task Metrics */} +
    +

    {t('tasks.metrics', 'Tasks')}

    +
    + {/* Left column */} +
    +
    +
    + +

    {t('tasks.backlog')}

    +
    +

    + {metrics.total_open_tasks} +

    +
    + +
    +
    + +

    {t('tasks.inProgress')}

    +
    +

    + {metrics.tasks_in_progress_count} +

    +
    +
    + + {/* Right column */} +
    +
    +
    + +

    {t('tasks.dueToday')}

    +
    +

    + {metrics.tasks_due_today.length} +

    +
    + +
    +
    + +

    {t('tasks.stale')}

    +
    +

    + {metrics.tasks_pending_over_month} +

    +
    +
    -
    - -
    -

    In Progress

    -

    - {metrics.tasks_in_progress_count} -

    -
    -
    - -
    - -
    -

    Due Today

    -

    - {metrics.tasks_due_today.length} -

    -
    -
    - -
    - -
    -

    Stale

    -

    - {metrics.tasks_pending_over_month} -

    + {/* Project Metrics */} +
    +

    {t('projects.metrics', 'Projects')}

    +
    +
    +
    + +

    {t('projects.active')}

    +
    +

    + {localProjects.filter(project => project.active).length} +

    +
    + +
    +
    + +

    {t('projects.inactive')}

    +
    +

    + {localProjects.filter(project => !project.active).length} +

    +
    + {/* Inbox Notification */} + {inboxItemsCount > 0 && ( +
    + + +
    +

    + {t('inbox.unprocessedItems', { count: inboxItemsCount, defaultValue: `You have ${inboxItemsCount} item(s) in your inbox.` })} +

    +

    + {t('inbox.processNow', 'Process them now')} +

    +
    + +
    + )} + {metrics.tasks_due_today.length > 0 && ( <> -

    Due Today

    +

    {t('tasks.dueToday')}

    )} {metrics.tasks_in_progress.length > 0 && ( <> -

    In Progress

    +

    {t('tasks.inProgress')}

    )} {metrics.suggested_tasks.length > 0 && ( <> -

    Suggested

    +

    {t('tasks.suggested')}

    )} - {tasks.length === 0 && ( + {localTasks.length === 0 && (

    - No tasks available for today. + {t('tasks.noTasksAvailable')}

    )}
    @@ -193,4 +348,4 @@ const TasksToday: React.FC = () => { ); }; -export default TasksToday; \ No newline at end of file +export default TasksToday; diff --git a/app/frontend/components/Task/getDescription.ts b/app/frontend/components/Task/getDescription.ts index 6af267a..3850888 100644 --- a/app/frontend/components/Task/getDescription.ts +++ b/app/frontend/components/Task/getDescription.ts @@ -1,31 +1,78 @@ import { Project } from "../../entities/Project"; -export const getDescription = (query: URLSearchParams, projects: Project[]): string => { - const projectId = query.get('project_id'); - if (projectId) { - const project = projects.find((p) => p.id?.toString() === projectId); - return project - ? `You are currently viewing all tasks associated with the "${project.name}" project. You can organize tasks within this project, set their priority, and track their completion. Use this space to focus on the tasks that belong specifically to this project.` - : 'You are viewing tasks for a specific project. Use this space to manage and track tasks associated with this project.'; - } +export const getDescription = ( + query: URLSearchParams, + projects: Project[], + t: (key: string, options?: any) => string +): string => { + 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", + completed: "Tasks you've completed", + 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); + if (project) { + return t("taskViews.project.withName", { projectName: project.name }); + } else { + return t("taskViews.project.noName"); + } + } catch (e) { + console.error("Translation error for project description:", e); + // Fallback with project name if available + const project = projects.find((p) => p.id?.toString() === projectId); + return project + ? `Tasks for project: ${project.name}` + : defaultDescriptions.project; + } + } - if (query.get('type') === 'today') { - return 'These are the tasks that are due today or tasks you’ve scheduled for immediate attention. Use this view to focus on what needs to be completed today. Mark tasks as completed, update their status, or adjust their due dates if needed.'; + // Then check for type and status parameters + try { + if (query.get('type') === 'today') { + return t("taskViews.today"); + } + if (query.get('type') === 'inbox') { + return t("taskViews.inbox"); + } + if (query.get('type') === 'next') { + return t("taskViews.next"); + } + if (query.get('type') === 'upcoming') { + return t("taskViews.upcoming"); + } + if (query.get('type') === 'someday') { + return t("taskViews.someday"); + } + if (query.get('status') === 'done') { + return t("taskViews.completed"); + } + return t("taskViews.allTasks"); + } catch (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; + return defaultDescriptions.allTasks; + } + } catch (error) { + console.error("Error in getDescription:", error); + return "Tasks overview"; } - if (query.get('type') === 'inbox') { - return 'The inbox is where all uncategorized tasks live. Tasks that haven’t been assigned to a project or given a due date will show up here. This is your “brain dump” area where you can quickly jot down tasks and organize them later.'; - } - if (query.get('type') === 'next') { - return 'This view shows all the tasks that are actionable in the near future. These tasks are ready to be worked on next and don’t have long-term deadlines. It’s a good place to focus when you’re looking to make quick progress on tasks.'; - } - if (query.get('type') === 'upcoming') { - return 'This view highlights tasks that are scheduled for the upcoming week. It helps you prepare and stay ahead of deadlines by giving you an overview of the work you need to tackle in the near future. Tasks with due dates within the next 7 days will appear here.'; - } - if (query.get('type') === 'someday') { - return 'The “Someday” view is for tasks that aren’t urgent and don’t have a specific due date. These are tasks you may want to get to at some point, but they aren’t a priority right now. Use this section to keep track of ideas or long-term goals.'; - } - if (query.get('status') === 'done') { - return 'Here you can see all the tasks you’ve completed. It’s a great way to review your accomplishments and reflect on the work you’ve finished. You can also find tasks that may need to be unarchived or referenced in the future.'; - } - return 'You are viewing all tasks. This includes tasks from different projects, tasks without specific due dates, and tasks with varying levels of priority. Use this view for an overall look at everything on your to-do list.'; }; \ No newline at end of file diff --git a/app/frontend/components/Task/getTitleAndIcon.ts b/app/frontend/components/Task/getTitleAndIcon.ts index cf042da..99cfe90 100644 --- a/app/frontend/components/Task/getTitleAndIcon.ts +++ b/app/frontend/components/Task/getTitleAndIcon.ts @@ -10,30 +10,63 @@ import { Bars4Icon, } from '@heroicons/react/24/outline'; -export const getTitleAndIcon = (query: URLSearchParams, projects: Project[]) => { +export const getTitleAndIcon = ( + query: URLSearchParams, + projects: Project[], + t: (key: string, options?: any) => string +) => { + try { + // Default titles as fallbacks in case translation function fails + const defaultTitles = { + project: 'Project', + today: 'Today', + inbox: 'Inbox', + next: 'Next Actions', + upcoming: 'Upcoming', + someday: 'Someday', + completed: 'Completed', + 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 : 'Project', icon: FolderIcon }; + return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon }; } - if (query.get('type') === 'today') { - return { title: 'Today', icon: CalendarIcon }; + try { + if (query.get('type') === 'today') { + return { title: t('tasks.today'), icon: CalendarIcon }; + } + if (query.get('type') === 'inbox') { + return { title: t('sidebar.inbox'), icon: InboxIcon }; + } + if (query.get('type') === 'next') { + 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 }; + } + 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); + + // 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 }; + return { title: defaultTitles.allTasks, icon: Bars4Icon }; } - if (query.get('type') === 'inbox') { - return { title: 'Inbox', icon: InboxIcon }; - } - if (query.get('type') === 'next') { - return { title: 'Next Actions', icon: ArrowRightIcon }; - } - if (query.get('type') === 'upcoming') { - return { title: 'Upcoming', icon: ClockIcon }; - } - if (query.get('type') === 'someday') { - return { title: 'Someday', icon: MoonIcon }; - } - if (query.get('status') === 'done') { - return { title: 'Completed', icon: CheckCircleIcon }; - } - return { title: 'All Tasks', icon: Bars4Icon }; +} catch (error) { + console.error("Error in getTitleAndIcon:", error); + return { title: "Tasks", icon: Bars4Icon }; +} }; diff --git a/app/frontend/components/Tasks.tsx b/app/frontend/components/Tasks.tsx index 4ea86a5..426e936 100644 --- a/app/frontend/components/Tasks.tsx +++ b/app/frontend/components/Tasks.tsx @@ -1,5 +1,6 @@ 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"; @@ -16,7 +17,22 @@ import { const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +// Helper function to get search placeholder by language +const getSearchPlaceholder = (language: string): string => { + const placeholders: Record = { + en: 'Search tasks...', + el: 'Αναζήτηση εργασιών...', + es: 'Buscar tareas...', + de: 'Aufgaben suchen...', + jp: 'タスクを検索...', + ua: 'Пошук завдань...' + }; + + return placeholders[language] || 'Search tasks...'; +}; + const Tasks: React.FC = () => { + const { t, i18n } = useTranslation(); const [tasks, setTasks] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); @@ -34,7 +50,7 @@ const Tasks: React.FC = () => { const { title, icon } = stateTitle && stateIcon ? { title: stateTitle, icon: stateIcon } - : getTitleAndIcon(query, projects); + : getTitleAndIcon(query, projects, t); const IconComponent = typeof icon === "string" ? React.createElement(icon) : icon; @@ -194,7 +210,7 @@ const Tasks: React.FC = () => { setDropdownOpen(false); }; - const description = getDescription(query, projects); + const description = getDescription(query, projects, t); const isNewTaskAllowed = () => { return status !== "done"; @@ -207,7 +223,6 @@ const Tasks: React.FC = () => { return (
    - {/* Title and Icon */}
    {IconComponent && } @@ -229,7 +244,6 @@ const Tasks: React.FC = () => { )}
    - {/* Sort Dropdown */}
    @@ -265,7 +279,7 @@ const Tasks: React.FC = () => { 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" > - {capitalize(order.split(":")[0].replace("_", " "))} + {t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))} ))}
    @@ -275,18 +289,16 @@ const Tasks: React.FC = () => {
    - {/* Description */}

    {description}

    - {/* Search Bar */}
    setTaskSearchQuery(e.target.value)} className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" @@ -294,7 +306,7 @@ const Tasks: React.FC = () => {
    {loading ? ( -

    Loading...

    +

    {t('common.loading', 'Loading...')}

    ) : error ? (

    {error}

    ) : ( @@ -308,7 +320,6 @@ const Tasks: React.FC = () => { /> )} - {/* Task List */} {filteredTasks.length > 0 ? ( { /> ) : (

    - No tasks available. + {t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}

    )} diff --git a/app/frontend/entities/InboxItem.ts b/app/frontend/entities/InboxItem.ts new file mode 100644 index 0000000..9ddfaf7 --- /dev/null +++ b/app/frontend/entities/InboxItem.ts @@ -0,0 +1,8 @@ +export interface InboxItem { + id?: number; + content: string; + status?: string; // 'added' | 'processed' | 'deleted' + source?: string; // 'tududi' | 'telegram' + created_at?: string; + updated_at?: string; +} \ No newline at end of file diff --git a/app/frontend/entities/User.ts b/app/frontend/entities/User.ts index ac1ede2..ed6af43 100644 --- a/app/frontend/entities/User.ts +++ b/app/frontend/entities/User.ts @@ -1,5 +1,8 @@ export interface User { id: number; email: string; + language: string; + appearance: string; + timezone: string; avatarUrl?: string; -} \ No newline at end of file +} diff --git a/app/frontend/i18n.ts b/app/frontend/i18n.ts new file mode 100644 index 0000000..6ab21fb --- /dev/null +++ b/app/frontend/i18n.ts @@ -0,0 +1,299 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +// Define required translations for the app to function even if translations fail to load +const fallbackResources = { + en: { + translation: { + common: { + loading: 'Loading...', + appLoading: 'Loading application... Please wait.', + error: 'Error', + }, + auth: { + login: 'Login', + register: 'Register', + }, + errors: { + somethingWentWrong: 'Something went wrong, please try again', + }, + }, + }, +}; + +// Explicitly add resources for development +const devResources = isDevelopment ? { + en: { + translation: fallbackResources.en.translation, + }, +} : undefined; + +console.log("Initializing i18n..."); +console.log("Environment:", process.env.NODE_ENV); + +// Create i18n instance +const i18nInstance = i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next); + +// Initialize i18n +i18nInstance.init({ + fallbackLng: 'en', + debug: isDevelopment, + + // Map language codes with region (e.g., 'en-US') to base language codes (e.g., 'en') + load: 'languageOnly', + + // Language mapping to handle specific cases + supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'], + nonExplicitSupportedLngs: true, + + // Add fallback resources to prevent rendering issues + resources: devResources, + + // Language detection options + detection: { + order: ['querystring', 'cookie', 'localStorage', 'navigator'], + lookupQuerystring: 'lng', + lookupCookie: 'i18next', + lookupLocalStorage: 'i18nextLng', + caches: ['localStorage', 'cookie'] + }, + + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + + // Default namespace configuration + defaultNS: 'translation', + ns: ['translation'], + + // Backend configuration for loading translations + backend: { + // Always use absolute path for development and production to avoid issues + loadPath: '/locales/{{lng}}/{{ns}}.json', + // Add deterministic cache busting parameter based on build timestamp + queryStringParams: { v: '1' }, + requestOptions: { + cache: 'default', // Use default browser caching to improve performance + credentials: 'same-origin', + mode: 'cors' + } + }, +}) +.then(() => { + console.log('i18n initialized successfully'); + console.log('Loaded languages:', i18n.languages); + console.log('Current language:', i18n.language); + console.log('Available namespaces:', i18n.options.ns); + console.log('Has translation bundle:', i18n.hasResourceBundle(i18n.language, 'translation')); + + // Try to load translations directly with both possible paths + const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`; + console.log(`Attempting to fetch translations from: ${loadPath}`); + + fetch(loadPath) + .then(response => { + console.log(`Manual fetch response: ${response.status} from ${loadPath}`); + if (!response.ok) { + // If first attempt fails and we're in development, try the alternative path + if (isDevelopment) { + console.log('First fetch attempt failed, trying alternative path'); + return fetch(`/locales/${i18n.language}/translation.json`); + } + throw new Error(`Failed to fetch translation: ${response.status}`); + } + return response.json(); + }) + .then(data => { + console.log('Translation data fetched manually:', Object.keys(data)); + i18n.addResourceBundle(i18n.language, 'translation', data, true, true); + console.log('Added resource bundle manually'); + }) + .catch(err => { + console.error('Error manually fetching translations:', err); + + // As a fallback, try to add translations from the public directory directly using require + if (isDevelopment) { + try { + console.log('Attempting to load translations using a different approach...'); + setTimeout(() => { + 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); + console.log('Added resource bundle via alternative approach'); + }) + .catch(e => console.error('Alternative loading approach failed:', e)); + }, 1000); + } catch (e) { + console.error('All attempts to load translations failed:', e); + } + } + }); +}) +.catch(error => { + console.error('i18n initialization error:', error); +}); + +// Register event listeners for debugging translation loading +i18n.on('initialized', (initialized) => { + console.log('i18n initialized event:', initialized); + console.log('Current language:', i18n.language); + console.log('Available languages:', i18n.languages); + console.log('Is initialized:', i18n.isInitialized); +}); + +i18n.on('loaded', (loaded) => { + console.log('Translations loaded event:', loaded); +}); + +i18n.on('failedLoading', (lng, ns, msg) => { + console.error(`Failed loading translation for ${lng}/${ns}: ${msg}`); +}); + +i18n.on('missingKey', (lngs, namespace, key, res) => { + console.warn(`Missing translation key: ${key} in namespace: ${namespace} for languages: ${lngs.join(', ')}`); +}); + +// Create a custom event for language changes that components can listen for +const dispatchLanguageChangeEvent = (lng: string) => { + console.log(`Dispatching language change event for: ${lng}`); + const event = new CustomEvent('app-language-changed', { detail: { language: lng } }); + window.dispatchEvent(event); +}; + +i18n.on('languageChanged', (lng) => { + console.log(`Language changed to: ${lng}`); + + // Store language in localStorage for persistence + localStorage.setItem('i18nextLng', lng); + + // Update HTML lang attribute for accessibility and SEO + document.documentElement.lang = lng; + + const handleTranslationsLoaded = () => { + // Dispatch a custom event after translations are loaded + // This helps components know when to re-render + dispatchLanguageChangeEvent(lng); + + // Force update any i18next instances + if (i18n.services && i18n.services.resourceStore) { + // This triggers internal i18next change notifications + const currentNS = i18n.options.defaultNS || 'translation'; + i18n.reloadResources(lng, currentNS); + } + }; + + // Ensure translations are loaded when language changes + if (!i18n.hasResourceBundle(lng, 'translation')) { + console.log(`Loading translations for language ${lng}`); + + const loadPath = isDevelopment + ? `./locales/${lng}/translation.json` + : `/locales/${lng}/translation.json`; + + fetch(loadPath) + .then(response => { + if (!response.ok) { + console.warn(`Failed to fetch translations for ${lng}: ${response.status}`); + // Try alternative path + return fetch(`/locales/${lng}/translation.json`); + } + return response; + }) + .then(response => response.json()) + .then(data => { + if (data) { + console.log(`Successfully loaded translations for ${lng}`); + i18n.addResourceBundle(lng, 'translation', data, true, true); + + // After translations are loaded, dispatch the event + handleTranslationsLoaded(); + } + }) + .catch(err => { + console.error(`Error loading translations for ${lng}:`, err); + // Even if loading fails, we should still dispatch event so UI updates + handleTranslationsLoaded(); + }); + } else { + console.log(`Translations for ${lng} already loaded, skipping fetch`); + // If translations are already loaded, dispatch the event immediately + handleTranslationsLoaded(); + } +}); + +// Add a function to manually check translation availability +// Add type declaration for the global function and custom events +declare global { + interface WindowEventMap { + 'app-language-changed': CustomEvent<{ language: string }>; + } + + interface Window { + checkTranslation: (key: string) => void; + forceLanguageReload: (lng?: string) => void; + } +} + +// Expose a function to manually check translations (helpful for debugging) +window.checkTranslation = (key: string) => { + try { + const translation = i18n.t(key); + console.log(`Translation for '${key}': ${translation}`); + console.log(`Is key '${key}' available: ${translation !== key}`); + return translation; + } catch (error) { + console.error(`Error checking translation for key '${key}':`, error); + return null; + } +}; + +// Add a global function to force language reload +window.forceLanguageReload = (lng?: string) => { + const targetLng = lng || i18n.language; + console.log(`Force reloading language: ${targetLng}`); + + // Force reload the resources for current language + i18n.reloadResources(targetLng, 'translation') + .then(() => { + console.log(`Resources reloaded for ${targetLng}`); + + // To guarantee a reload effect: + // 1. First dispatch the event + dispatchLanguageChangeEvent(targetLng); + + // 2. Force i18next to refresh its cache and notify all components + if (i18n.services && i18n.services.resourceStore) { + Object.values(i18n.services.resourceStore.data).forEach(lang => { + // Add a proper type guard to check if translation exists and is an object + if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) { + // Touch the translation object to ensure React detects changes + const temp = {...lang.translation as Record}; + lang.translation = temp; + } + }); + } + + // 3. Explicitly change language if needed + if (lng) { + setTimeout(() => { + i18n.changeLanguage(targetLng); + }, 50); // Small delay to ensure the DOM has time to update + } + }) + .catch(err => { + console.error(`Error reloading resources: ${err}`); + }); +}; + +export default i18n; diff --git a/app/frontend/index.tsx b/app/frontend/index.tsx index 311f0ea..b40b7a4 100644 --- a/app/frontend/index.tsx +++ b/app/frontend/index.tsx @@ -1,8 +1,18 @@ +// Add type declaration for module.hot +declare const module: { + hot?: { + accept: (path: string, callback: () => void) => void; + }; +}; + 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 { 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; @@ -18,13 +28,37 @@ if (isDarkMode) { const container = document.getElementById("root"); +// Store the root outside the if block so it can be accessed by the HMR code +let root: any; + if (container) { - const root = createRoot(container); + root = createRoot(container); root.render( - - - - - + + + + + + + ); } + +// Hot Module Replacement (HMR) - Remove this snippet to remove HMR. +// Learn more: https://www.webpackjs.com/concepts/hot-module-replacement/ +if (module.hot) { + module.hot.accept('./App', () => { + // New version of App component imported + if (root) { + root.render( + + + + + + + + ); + } + }); +} diff --git a/app/frontend/store/useStore.ts b/app/frontend/store/useStore.ts index 029d47f..f9b5e27 100644 --- a/app/frontend/store/useStore.ts +++ b/app/frontend/store/useStore.ts @@ -4,6 +4,7 @@ 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[]; @@ -50,12 +51,25 @@ interface TasksStore { setError: (isError: boolean) => void; } +interface InboxStore { + inboxItems: InboxItem[]; + isLoading: boolean; + isError: boolean; + setInboxItems: (inboxItems: InboxItem[]) => void; + addInboxItem: (inboxItem: InboxItem) => void; + updateInboxItem: (inboxItem: InboxItem) => void; + removeInboxItem: (id: number) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + interface StoreState { notesStore: NotesStore; areasStore: AreasStore; projectsStore: ProjectsStore; tagsStore: TagsStore; tasksStore: TasksStore; + inboxStore: InboxStore; } export const useStore = create((set) => ({ @@ -99,4 +113,38 @@ export const useStore = create((set) => ({ 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 } + })), + addInboxItem: (inboxItem) => set((state) => ({ + inboxStore: { + ...state.inboxStore, + inboxItems: [...state.inboxStore.inboxItems, inboxItem] + } + })), + updateInboxItem: (inboxItem) => set((state) => ({ + inboxStore: { + ...state.inboxStore, + inboxItems: state.inboxStore.inboxItems.map(item => + item.id === inboxItem.id ? inboxItem : item + ) + } + })), + removeInboxItem: (id) => set((state) => ({ + inboxStore: { + ...state.inboxStore, + inboxItems: state.inboxStore.inboxItems.filter(item => item.id !== id) + } + })), + setLoading: (isLoading) => set((state) => ({ + inboxStore: { ...state.inboxStore, isLoading } + })), + setError: (isError) => set((state) => ({ + inboxStore: { ...state.inboxStore, isError } + })), + }, })); \ No newline at end of file diff --git a/app/frontend/styles/tailwind.css b/app/frontend/styles/tailwind.css index 2ff0b09..ed646ee 100644 --- a/app/frontend/styles/tailwind.css +++ b/app/frontend/styles/tailwind.css @@ -3,6 +3,21 @@ @tailwind components; @tailwind utilities; +.spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + width: 36px; + height: 36px; + border-radius: 50%; + border-left-color: #09f; + animation: spin 1s ease infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + input:focus, select:focus, textarea:focus { outline: none; box-shadow: none; diff --git a/app/frontend/utils/dateUtils.ts b/app/frontend/utils/dateUtils.ts new file mode 100644 index 0000000..e039567 --- /dev/null +++ b/app/frontend/utils/dateUtils.ts @@ -0,0 +1,115 @@ +import { format, Locale } from 'date-fns'; +import { enUS } from 'date-fns/locale/en-US'; +import { es } from 'date-fns/locale/es'; +import { el } from 'date-fns/locale/el'; +import i18n from '../i18n'; + +/** + * Maps i18next language codes to date-fns locale objects + */ +const localeMap: Record = { + en: enUS, + es: es, + el: el, +}; + +/** + * Returns the date-fns locale object based on the current i18next language + * Falls back to English if the current language is not supported + */ +export const getCurrentLocale = (): Locale => { + const language = i18n.language || 'en'; + return localeMap[language] || enUS; +}; + +/** + * Formats a date using the current locale from i18next + * + * @param date - The date to format + * @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 => { + return format(date, formatStr, { + locale: getCurrentLocale(), + }); +}; + +/** + * Gets the date format pattern from translation file + * + * @param formatKey - The key for the format in the dateFormats object + * @param fallback - Fallback format to use if translation is missing + * @returns The format pattern 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; +}; + +/** + * Formats a date in a long readable format based on the current locale + * Example: "Monday, January 1, 2023" (in English) + * + * @param date - The date to format + * @returns The formatted date string + */ +export const formatLongDate = (date: Date | number): string => { + return formatLocalizedDate(date, getDateFormatPattern('long', 'EEEE, MMMM d, yyyy')); +}; + +/** + * Formats a date in a short format based on the current locale + * Example: "Jan 1, 2023" (in English) + * + * @param date - The date to format + * @returns The formatted date string + */ +export const formatShortDate = (date: Date | number): string => { + return formatLocalizedDate(date, getDateFormatPattern('short', 'MMM d, yyyy')); +}; + +/** + * Formats a date to show only month and year based on the current locale + * Example: "January 2023" (in English) + * + * @param date - The date to format + * @returns The formatted date string + */ +export const formatMonthYear = (date: Date | number): string => { + return formatLocalizedDate(date, getDateFormatPattern('monthYear', 'MMMM yyyy')); +}; + +/** + * Formats a date to show only day and month based on the current locale + * Example: "January 1" (in English) + * + * @param date - The date to format + * @returns The formatted date string + */ +export const formatDayMonth = (date: Date | number): string => { + return formatLocalizedDate(date, getDateFormatPattern('dayMonth', 'MMMM d')); +}; + +/** + * Formats a date to show only time based on the current locale + * Example: "3:30 PM" (in English) + * + * @param date - The date to format + * @returns The formatted time string + */ +export const formatTime = (date: Date | number): string => { + return formatLocalizedDate(date, getDateFormatPattern('time', 'h:mm a')); +}; + +/** + * Formats a date to show date and time based on the current locale + * Example: "Jan 1, 2023 3:30 PM" (in English) + * + * @param date - The date to format + * @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')); +}; diff --git a/app/frontend/utils/inboxService.ts b/app/frontend/utils/inboxService.ts new file mode 100644 index 0000000..0296389 --- /dev/null +++ b/app/frontend/utils/inboxService.ts @@ -0,0 +1,165 @@ +import { InboxItem } from "../entities/InboxItem"; +import { useStore } from "../store/useStore"; + +// API functions +export const fetchInboxItems = async (): Promise => { + const response = await fetch('/api/inbox'); + + if (!response.ok) throw new Error('Failed to fetch inbox items.'); + + const result = await response.json(); + + if (!Array.isArray(result)) { + throw new Error('Resulting inbox items are not an array.'); + } + + return result; +}; + +export const createInboxItem = async (content: string, source: string = 'tududi'): Promise => { + const response = await fetch('/api/inbox', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, source }), + }); + + if (!response.ok) throw new Error('Failed to create inbox item.'); + + return await response.json(); +}; + +export const updateInboxItem = async (itemId: number, content: string): Promise => { + const response = await fetch(`/api/inbox/${itemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) throw new Error('Failed to update inbox item.'); + + return await response.json(); +}; + +export const processInboxItem = async (itemId: number): Promise => { + const response = await fetch(`/api/inbox/${itemId}/process`, { + method: 'PATCH', + }); + + if (!response.ok) throw new Error('Failed to process inbox item.'); + + return await response.json(); +}; + +export const deleteInboxItem = async (itemId: number): Promise => { + const response = await fetch(`/api/inbox/${itemId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete inbox item.'); +}; + +// Track last check time to detect new items +let lastCheckTimestamp = Date.now(); + +// Store-aware functions +export const loadInboxItemsToStore = async (): Promise => { + const inboxStore = useStore.getState().inboxStore; + // Only show loading for initial load + if (inboxStore.inboxItems.length === 0) { + inboxStore.setLoading(true); + } + + try { + const items = await fetchInboxItems(); + + // Check for new items since last check + const currentItemIds = new Set(inboxStore.inboxItems.map(item => item.id)); + const currentTime = Date.now(); + + // New telegram items + const newTelegramItems = items.filter(item => + item.id && + !currentItemIds.has(item.id) && + item.source === 'telegram' + ); + + // Only show notifications if we have detected changes + if (inboxStore.inboxItems.length > 0 && newTelegramItems.length > 0) { + // Instead of trying to show toast directly (which won't work outside of React components), + // dispatch a custom event that the component can listen for and show toasts + + // 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 ? '...' : '') + }; + + // Dispatch a custom event with the notification data + window.dispatchEvent(new CustomEvent('inboxItemsUpdated', { + detail: notificationData + })); + } + + // Update state and timestamp + inboxStore.setInboxItems(items); + inboxStore.setError(false); + lastCheckTimestamp = currentTime; + } catch (error) { + console.error('Failed to load inbox items:', error); + inboxStore.setError(true); + } finally { + inboxStore.setLoading(false); + } +}; + +export const createInboxItemWithStore = async (content: string, source: string = 'tududi'): Promise => { + const inboxStore = useStore.getState().inboxStore; + + try { + const newItem = await createInboxItem(content, source); + inboxStore.addInboxItem(newItem); + return newItem; + } catch (error) { + console.error('Failed to create inbox item:', error); + throw error; + } +}; + +export const updateInboxItemWithStore = async (itemId: number, content: string): Promise => { + const inboxStore = useStore.getState().inboxStore; + + try { + const updatedItem = await updateInboxItem(itemId, content); + inboxStore.updateInboxItem(updatedItem); + return updatedItem; + } catch (error) { + console.error('Failed to update inbox item:', error); + throw error; + } +}; + +export const processInboxItemWithStore = async (itemId: number): Promise => { + const inboxStore = useStore.getState().inboxStore; + + try { + const processedItem = await processInboxItem(itemId); + inboxStore.removeInboxItem(itemId); + return processedItem; + } catch (error) { + console.error('Failed to process inbox item:', error); + throw error; + } +}; + +export const deleteInboxItemWithStore = async (itemId: number): Promise => { + const inboxStore = useStore.getState().inboxStore; + + try { + await deleteInboxItem(itemId); + inboxStore.removeInboxItem(itemId); + } catch (error) { + console.error('Failed to delete inbox item:', error); + throw error; + } +}; \ No newline at end of file diff --git a/app/frontend/utils/notesService.ts b/app/frontend/utils/notesService.ts index 060af6b..d9180b4 100644 --- a/app/frontend/utils/notesService.ts +++ b/app/frontend/utils/notesService.ts @@ -8,19 +8,29 @@ export const fetchNotes = async (): Promise => { }; export const createNote = async (noteData: Note): Promise => { - const response = await fetch('/api/notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(noteData), - }); + try { + console.log("Creating note with data:", JSON.stringify(noteData, null, 2)); + const response = await fetch('/api/note', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(noteData), + }); - if (!response.ok) throw new Error('Failed to create note.'); + if (!response.ok) { + const errorData = await response.json(); + console.error("Error creating note:", errorData); + throw new Error(`Failed to create note: ${JSON.stringify(errorData)}`); + } - return await response.json(); + return await response.json(); + } catch (error) { + console.error("Exception in createNote:", error); + throw error; + } }; export const updateNote = async (noteId: number, noteData: Note): Promise => { - const response = await fetch(`/api/notes/${noteId}`, { + const response = await fetch(`/api/note/${noteId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(noteData), @@ -32,7 +42,7 @@ export const updateNote = async (noteId: number, noteData: Note): Promise }; export const deleteNote = async (noteId: number): Promise => { - const response = await fetch(`/api/notes/${noteId}`, { + const response = await fetch(`/api/note/${noteId}`, { method: 'DELETE', }); diff --git a/app/frontend/utils/tagsService.ts b/app/frontend/utils/tagsService.ts index ba535b8..2b0860b 100644 --- a/app/frontend/utils/tagsService.ts +++ b/app/frontend/utils/tagsService.ts @@ -1,10 +1,23 @@ import { Tag } from "../entities/Tag"; export const fetchTags = async (): Promise => { - const response = await fetch("/api/tags"); - if (!response.ok) throw new Error('Failed to fetch tags.'); + try { + const response = await fetch("/api/tags", { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + if (!response.ok) throw new Error('Failed to fetch tags.'); - return await response.json(); + return await response.json(); + } catch (error) { + console.error("Tags fetch error:", error); + // Return empty array to prevent UI from breaking + return []; + } }; export const createTag = async (tagData: Tag): Promise => { diff --git a/app/frontend/utils/urlService.ts b/app/frontend/utils/urlService.ts new file mode 100644 index 0000000..e7b3afd --- /dev/null +++ b/app/frontend/utils/urlService.ts @@ -0,0 +1,71 @@ +/** + * Service for URL-related operations like extracting titles from web pages + */ + +export interface UrlTitleResult { + url: string; + title: string | null; + found?: boolean; + error?: string; +} + +/** + * Extract the title of a web page from its URL + * @param url The URL to extract the title from + * @returns Promise resolving to the page title or null if not found + */ +export const extractUrlTitle = async (url: string): Promise => { + try { + const response = await fetch(`/api/url/title?url=${encodeURIComponent(url)}`); + + if (!response.ok) { + throw new Error('Failed to extract URL title'); + } + + return await response.json(); + } catch (error) { + console.error('Error extracting URL title:', error); + return { url, title: null, error: (error as Error).message }; + } +}; + +/** + * Extract a URL and its title from arbitrary text + * @param text The text that might contain a URL + * @returns Promise resolving to the URL and title if found + */ +export const extractTitleFromText = async (text: string): Promise => { + try { + const response = await fetch('/api/url/extract-from-text', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + throw new Error('Failed to extract title from text'); + } + + const result = await response.json(); + + if (result.found === false) { + return null; + } + + return result; + } catch (error) { + console.error('Error extracting title from text:', error); + return null; + } +}; + +/** + * Check if a string is likely a URL + * @param text The text to check + * @returns True if the text appears to be a URL + */ +export const isUrl = (text: string): boolean => { + // Basic URL validation regex + const urlRegex = /^(https?:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i; + return urlRegex.test(text.trim()); +}; \ No newline at end of file diff --git a/app/models/inbox_item.rb b/app/models/inbox_item.rb new file mode 100644 index 0000000..89e7318 --- /dev/null +++ b/app/models/inbox_item.rb @@ -0,0 +1,22 @@ +class InboxItem < ActiveRecord::Base + belongs_to :user + + enum status: { added: 'added', processed: 'processed', deleted: 'deleted' } + enum source: { tududi: 'tududi', telegram: 'telegram' } + + scope :active, -> { where(status: 'added') } + scope :processed, -> { where(status: 'processed') } + scope :by_source, ->(source) { where(source: source) } + + validates :content, presence: true + validates :status, inclusion: { in: statuses.keys } + validates :source, inclusion: { in: sources.keys } + + def mark_as_processed! + update(status: 'processed') + end + + def mark_as_deleted! + update(status: 'deleted') + end +end diff --git a/app/models/task.rb b/app/models/task.rb index 9d86ffd..f89b74e 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -6,7 +6,6 @@ class Task < ActiveRecord::Base enum priority: { low: 0, medium: 1, high: 2 } enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 } - # Existing scopes scope :complete, -> { where(status: statuses[:done]) } scope :incomplete, -> { where.not(status: statuses[:done]) } scope :due_today, -> { incomplete.where('DATE(due_date) <= ?', Date.today) } @@ -31,7 +30,6 @@ class Task < ActiveRecord::Base validates :name, presence: true, uniqueness: { scope: :user_id } - # New class method to filter tasks based on params def self.filter_by_params(params, user) tasks = user.tasks.includes(:project, :tags) @@ -92,23 +90,22 @@ class Task < ActiveRecord::Base # Gather tasks in projects expiring starting today, order by task priority tasks_in_expiring_projects = user.tasks.incomplete - .joins(:project) - .where('projects.due_date_at >= ?', Date.today) - .where(projects: { active: true }) # Only active projects - .where.not(id: excluded_task_ids) - .order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC')) - .limit(5) + .joins(:project) + .where('projects.due_date_at >= ?', Date.today) + .where(projects: { active: true }) # Only active projects + .where.not(id: excluded_task_ids) + .order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC')) + .limit(5) # Gather tasks not assigned to projects expiring today, ordered by task priority tasks_without_projects = user.tasks.incomplete - .where(status: statuses[:not_started], project_id: nil) - .or(user.tasks.where(project_id: nil, status: statuses[:not_started])) - .where.not(id: excluded_task_ids) - .order(priority: :desc) - .limit(5) + .where(status: statuses[:not_started], project_id: nil) + .or(user.tasks.where(project_id: nil, status: statuses[:not_started])) + .where.not(id: excluded_task_ids) + .order(priority: :desc) + .limit(5) # Combine both list of suggested tasks - suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects) { total_open_tasks: total_open_tasks, @@ -130,27 +127,27 @@ class Task < ActiveRecord::Base end # Parse or default the project due date - project_due_date = if (task.project&.due_date_at).is_a?(String) + project_due_date = if task.project&.due_date_at.is_a?(String) Date.parse(task&.project&.due_date_at) else task.project&.due_date_at || Date.new(9999, 12, 31) end # Priority in descending order (sorted values should be negative for sort_by) - priority_value = -(Task.priorities.fetch(task.priority, -1)) + priority_value = -Task.priorities.fetch(task.priority, -1) # Determine sorting flags based on various criteria - is_high_priority_proj_with_due_date = (task.priority == 'high' && task&.project&.due_date_at) ? 0 : 1 - is_high_priority_with_due_date = (task.priority == 'high' && task.due_date) ? 0 : 1 - is_high_priority = (task.priority == 'high' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + is_high_priority_proj_with_due_date = task.priority == 'high' && task&.project&.due_date_at ? 0 : 1 + is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1 + is_high_priority = task.priority == 'high' && !task.due_date && !task&.project&.due_date_at ? 0 : 1 - is_medium_priority_proj_with_due_date = (task.priority == 'medium' && task&.project&.due_date_at) ? 0 : 1 - is_medium_priority_with_due_date = (task.priority == 'medium' && task.due_date) ? 0 : 1 - is_medium_priority = (task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + is_medium_priority_proj_with_due_date = task.priority == 'medium' && task&.project&.due_date_at ? 0 : 1 + is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1 + is_medium_priority = task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at ? 0 : 1 - is_low_priority_proj_with_due_date = (task.priority == 'low' && task&.project&.due_date_at) ? 0 : 1 - is_low_priority_with_due_date = (task.priority == 'low' && task.due_date) ? 0 : 1 - is_low_priority = (task.priority == 'low' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + is_low_priority_proj_with_due_date = task.priority == 'low' && task&.project&.due_date_at ? 0 : 1 + is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1 + is_low_priority = task.priority == 'low' && !task.due_date && !task&.project&.due_date_at ? 0 : 1 # Primary sorting criteria [ diff --git a/app/models/user.rb b/app/models/user.rb index 61eee87..d95a156 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,16 +1,20 @@ class User < ActiveRecord::Base has_secure_password + TASK_SUMMARY_FREQUENCIES = %w[daily weekdays weekly 1h 2h 4h 8h 12h].freeze + has_many :tasks, dependent: :destroy has_many :projects, dependent: :destroy has_many :areas, dependent: :destroy has_many :notes, dependent: :destroy has_many :tags, dependent: :destroy + has_many :inbox_items, dependent: :destroy validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true validates :appearance, inclusion: { in: %w[light dark] } validates :language, presence: true validates :timezone, presence: true + validates :task_summary_frequency, inclusion: { in: TASK_SUMMARY_FREQUENCIES }, allow_nil: true # has_one_attached :avatar_image end diff --git a/app/routes/authentication_routes.rb b/app/routes/authentication_routes.rb index ff60385..185b07f 100644 --- a/app/routes/authentication_routes.rb +++ b/app/routes/authentication_routes.rb @@ -5,7 +5,7 @@ class Sinatra::Application content_type :json if logged_in? - { user: { email: current_user.email, id: current_user.id } }.to_json + { user: { email: current_user.email, id: current_user.id, language: current_user.language, appearance: current_user.appearance, timezone: current_user.timezone } }.to_json else { user: nil }.to_json end @@ -30,7 +30,7 @@ class Sinatra::Application if user&.authenticate(password) session[:user_id] = user.id status 200 - { user: { email: user.email, id: user.id } }.to_json + { user: { email: user.email, id: user.id, language: user.language, appearance: user.appearance, timezone: user.timezone } }.to_json else halt 401, { errors: ['Invalid credentials'] }.to_json end diff --git a/app/routes/inbox_routes.rb b/app/routes/inbox_routes.rb new file mode 100644 index 0000000..f955dfa --- /dev/null +++ b/app/routes/inbox_routes.rb @@ -0,0 +1,92 @@ +module Sinatra + class Application + get '/api/inbox' do + content_type :json + + items = current_user.inbox_items.where(status: 'added').order(created_at: :desc) + items.to_json + end + + post '/api/inbox' do + content_type :json + + request_body = request.body.read + item_data = begin + JSON.parse(request_body) + rescue JSON::ParserError => e + halt 400, { error: 'Invalid JSON format.' }.to_json + end + + item = current_user.inbox_items.build( + content: item_data['content'], + source: item_data['source'] || 'tududi' + ) + + if item.save + status 201 + item.to_json + else + errors = item.errors.full_messages + halt 400, { error: 'There was a problem creating the inbox item.', details: errors }.to_json + end + end + + patch '/api/inbox/:id' do + content_type :json + + item = current_user.inbox_items.find_by(id: params[:id]) + halt 404, { error: 'Inbox item not found.' }.to_json unless item + + request_body = request.body.read + item_data = begin + JSON.parse(request_body) + rescue JSON::ParserError => e + halt 400, { error: 'Invalid JSON format.' }.to_json + end + + if item.update(content: item_data['content']) + item.to_json + else + errors = item.errors.full_messages + halt 400, { error: 'There was a problem updating the inbox item.', details: errors }.to_json + end + end + + patch '/api/inbox/:id/process' do + content_type :json + + item = current_user.inbox_items.find_by(id: params[:id]) + halt 404, { error: 'Inbox item not found.' }.to_json unless item + + if item.mark_as_processed! + item.to_json + else + halt 400, { error: 'There was a problem processing the inbox item.' }.to_json + end + end + + # Mark an inbox item as deleted + delete '/api/inbox/:id' do + content_type :json + + item = current_user.inbox_items.find_by(id: params[:id]) + halt 404, { error: 'Inbox item not found.' }.to_json unless item + + if item.mark_as_deleted! + { message: 'Inbox item successfully deleted' }.to_json + else + halt 400, { error: 'There was a problem deleting the inbox item.' }.to_json + end + end + + # Get a specific inbox item by ID + get '/api/inbox/:id' do + content_type :json + + item = current_user.inbox_items.find_by(id: params[:id]) + halt 404, { error: 'Inbox item not found.' }.to_json unless item + + item.to_json + end + end +end diff --git a/app/routes/notes_routes.rb b/app/routes/notes_routes.rb index b18cac9..23f92fa 100644 --- a/app/routes/notes_routes.rb +++ b/app/routes/notes_routes.rb @@ -60,7 +60,16 @@ class Sinatra::Application end if note.save - update_note_tags(note, note_data[:tags]) + # Handle tags array whether it's an array of strings or an array of objects with name property + tag_names = if note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(String) } + note_data[:tags] + elsif note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(Hash) && t[:name] } + note_data[:tags].map { |t| t[:name] } + else + [] + end + + update_note_tags(note, tag_names) status 201 note.to_json(include: :tags) else @@ -91,7 +100,18 @@ class Sinatra::Application end if note.update(note_attributes) - update_note_tags(note, request_data['tags']) + # Handle tags array whether it's an array of strings or an array of objects with name property + tag_names = if request_data['tags'].is_a?(Array) && request_data['tags'].all? { |t| t.is_a?(String) } + request_data['tags'] + elsif request_data['tags'].is_a?(Array) && request_data['tags'].all? do |t| + t.is_a?(Hash) && t['name'] + end + request_data['tags'].map { |t| t['name'] } + else + [] + end + + update_note_tags(note, tag_names) note.to_json(include: :tags) else status 400 diff --git a/app/routes/tags_routes.rb b/app/routes/tags_routes.rb index 5175c1f..0ff656f 100644 --- a/app/routes/tags_routes.rb +++ b/app/routes/tags_routes.rb @@ -44,6 +44,7 @@ class Sinatra::Application if tag.save tag.as_json(only: %i[id name]).to_json + else status 400 { error: 'There was a problem updating the tag.' }.to_json end diff --git a/app/routes/telegram_poller.rb b/app/routes/telegram_poller.rb new file mode 100644 index 0000000..decaf1a --- /dev/null +++ b/app/routes/telegram_poller.rb @@ -0,0 +1,231 @@ +require 'net/http' +require 'uri' +require 'json' +require 'thread' + +# A class to handle polling for Telegram updates +class TelegramPoller + @@instance = nil + @@mutex = Mutex.new + + attr_reader :running, :thread, :poll_interval, :last_update_id, :users_to_poll + + def initialize + @running = false + @thread = nil + @poll_interval = 5 # seconds + @last_update_id = 0 + @users_to_poll = [] + + # Keep a record of which users have active polling + @user_status = {} + end + + def self.instance + @@mutex.synchronize do + @@instance ||= new + end + @@instance + end + + # Start polling for a specific user + def add_user(user) + return false unless user && user.telegram_bot_token + + @users_to_poll << user unless @users_to_poll.any? { |u| u.id == user.id } + + # Start the polling thread if not already running + start_polling if @users_to_poll.any? && !@running + + true + end + + # Remove a user from polling + def remove_user(user_id) + @users_to_poll.reject! { |u| u.id == user_id } + + # Stop polling if no users left + stop_polling if @users_to_poll.empty? && @running + + true + end + + # Start the polling thread + def start_polling + return if @running + + @running = true + @thread = Thread.new do + while @running + begin + poll_updates + rescue => e + puts "Error polling Telegram: #{e.message}" + puts e.backtrace.join("\n") + end + + sleep @poll_interval + end + end + end + + # Stop the polling thread + def stop_polling + return unless @running + + @running = false + @thread.join if @thread + @thread = nil + end + + # Poll for updates from Telegram + def poll_updates + @users_to_poll.each do |user| + token = user.telegram_bot_token + next unless token + + begin + # Get updates from Telegram + uri = URI.parse("https://api.telegram.org/bot#{token}/getUpdates") + + params = { + offset: @user_status[user.id]&.dig(:last_update_id).to_i + 1, + timeout: 1 # Short timeout for quick polling + } + + uri.query = URI.encode_www_form(params) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 5 + + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + if response.code == '200' + data = JSON.parse(response.body) + + if data['ok'] && data['result'].is_a?(Array) + process_updates(user, data['result']) + end + else + puts "Error polling Telegram for user #{user.id}: #{response.code} #{response.message}" + end + rescue => e + puts "Error getting updates for user #{user.id}: #{e.message}" + end + end + end + + # Process updates received from Telegram + def process_updates(user, updates) + return if updates.empty? + + # Track the highest update_id to avoid processing the same update twice + highest_update_id = updates.map { |u| u['update_id'].to_i }.max || 0 + + # Save the last update ID for this user + @user_status[user.id] ||= {} + @user_status[user.id][:last_update_id] = highest_update_id if highest_update_id > (@user_status[user.id][:last_update_id] || 0) + + updates.each do |update| + begin + # Process message updates + if update['message'] && update['message']['text'] + process_message(user, update) + end + rescue => e + puts "Error processing update #{update['update_id']}: #{e.message}" + end + end + end + + # Process a single message + def process_message(user, update) + message = update['message'] + text = message['text'] + chat_id = message['chat']['id'].to_s + message_id = message['message_id'] + + puts "Processing message from user #{user.id}: #{text}" + + # Save the chat_id if not already saved + if user.telegram_chat_id.nil? || user.telegram_chat_id.empty? + puts "Updating user's telegram_chat_id to #{chat_id}" + user.update(telegram_chat_id: chat_id) + end + + # Create an inbox item + inbox_item = user.inbox_items.build( + content: text, + source: 'telegram' + ) + + if inbox_item.save + puts "Created inbox item #{inbox_item.id} from Telegram message" + + # Send confirmation + begin + send_telegram_message( + user.telegram_bot_token, + chat_id, + "✅ Added to Tududi inbox: \"#{text}\"", + message_id + ) + rescue => e + puts "Error sending confirmation: #{e.message}" + end + else + puts "Failed to create inbox item: #{inbox_item.errors.full_messages.join(', ')}" + + # Send error message + begin + send_telegram_message( + user.telegram_bot_token, + chat_id, + "❌ Failed to add to inbox: #{inbox_item.errors.full_messages.join(', ')}", + message_id + ) + rescue => e + puts "Error sending error message: #{e.message}" + end + end + end + + # Send a message to Telegram + def send_telegram_message(token, chat_id, text, reply_to_message_id = nil) + uri = URI.parse("https://api.telegram.org/bot#{token}/sendMessage") + + # Prepare message parameters + message_params = { + chat_id: chat_id, + text: text, + parse_mode: "MarkdownV2" + } + + # Add reply_to_message_id if provided + message_params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id + + # Send the request to Telegram API + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json') + request.body = message_params.to_json + + response = http.request(request) + return JSON.parse(response.body) + end + + # Get status of the poller + def status + { + running: @running, + users_count: @users_to_poll.size, + poll_interval: @poll_interval, + user_status: @user_status + } + end +end + +# Initialize the poller when this file is loaded +TelegramPoller.instance \ No newline at end of file diff --git a/app/routes/telegram_routes.rb b/app/routes/telegram_routes.rb new file mode 100644 index 0000000..791e446 --- /dev/null +++ b/app/routes/telegram_routes.rb @@ -0,0 +1,160 @@ +require 'net/http' +require 'uri' +require 'json' +require_relative 'telegram_poller' + +module Sinatra + class Application + # Start polling for a user + post '/api/telegram/start-polling' do + content_type :json + + # Get the current user's Telegram token + user = current_user + halt 400, { error: 'Telegram bot token not set.' }.to_json unless user.telegram_bot_token + + # Add the user to the polling list + if TelegramPoller.instance.add_user(user) + { + success: true, + message: 'Telegram polling started', + status: TelegramPoller.instance.status + }.to_json + else + halt 500, { error: 'Failed to start Telegram polling.' }.to_json + end + end + + # Stop polling for a user + post '/api/telegram/stop-polling' do + content_type :json + + user = current_user + + # Remove the user from the polling list + if TelegramPoller.instance.remove_user(user.id) + { + success: true, + message: 'Telegram polling stopped', + status: TelegramPoller.instance.status + }.to_json + else + halt 500, { error: 'Failed to stop Telegram polling.' }.to_json + end + end + + # Get polling status + get '/api/telegram/polling-status' do + content_type :json + + { + success: true, + status: TelegramPoller.instance.status, + is_polling: TelegramPoller.instance.users_to_poll.any? { |u| u.id == current_user.id } + }.to_json + end + + # Setup the Telegram bot for a user (save token and start polling) + post '/api/telegram/setup' do + content_type :json + request_body = request.body.read + + begin + setup_data = JSON.parse(request_body) + rescue JSON::ParserError + halt 400, { error: 'Invalid JSON format.' }.to_json + end + + token = setup_data['token'] + halt 400, { error: 'Telegram bot token is required.' }.to_json unless token && !token.empty? + + # Validate the token by making a getMe request to Telegram + begin + uri = URI.parse("https://api.telegram.org/bot#{token}/getMe") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + response = http.get(uri.request_uri) + json_response = JSON.parse(response.body) + + if json_response['ok'] + # Token is valid, save it to the user + bot_username = json_response['result']['username'] + current_user.update(telegram_bot_token: token) + + # Start polling for this user + TelegramPoller.instance.add_user(current_user) + + # Return success with bot info + { + success: true, + message: 'Telegram bot configured successfully and polling started!', + bot: { + username: bot_username, + polling_status: TelegramPoller.instance.status, + chat_url: "https://t.me/#{bot_username}" + } + }.to_json + else + halt 400, { error: 'Invalid Telegram bot token.', details: json_response['description'] }.to_json + end + rescue => e + halt 500, { error: 'Error validating Telegram bot token.', details: e.message }.to_json + end + end + + # Test endpoint to simulate a Telegram message (for development) + post '/api/telegram/test/:user_id' do + content_type :json + request_body = request.body.read + + begin + message_data = JSON.parse(request_body) + rescue JSON::ParserError + halt 400, { error: 'Invalid JSON format.' }.to_json + end + + user_id = params[:user_id] + user = User.find_by(id: user_id) + halt 404, { error: 'User not found.' }.to_json unless user + halt 400, { error: 'User has no Telegram bot token configured.' }.to_json unless user.telegram_bot_token + + text = message_data['text'] || 'Test message from development environment' + + # Create an inbox item directly + inbox_item = user.inbox_items.build( + content: text, + source: 'telegram' + ) + + if inbox_item.save + # Send confirmation to Telegram if the user has a chat_id + if user.telegram_chat_id + begin + # Use the TelegramPoller's send_message method + response = TelegramPoller.instance.send_telegram_message( + user.telegram_bot_token, + user.telegram_chat_id, + "✅ Added to Tududi inbox: \"#{text}\"" + ) + puts "Test message confirmation sent: #{response}" + rescue => e + puts "Error sending test confirmation: #{e.message}" + end + end + + { + success: true, + message: 'Test Telegram message processed successfully!', + inbox_item_id: inbox_item.id + }.to_json + else + { + success: false, + message: 'Failed to create inbox item from test message', + errors: inbox_item.errors.full_messages + }.to_json + end + end + end +end \ No newline at end of file diff --git a/app/routes/url_routes.rb b/app/routes/url_routes.rb new file mode 100644 index 0000000..aa71605 --- /dev/null +++ b/app/routes/url_routes.rb @@ -0,0 +1,43 @@ +require_relative '../services/url_title_extractor_service' + +module Sinatra + class Application + get '/api/url/title' do + content_type :json + + url = params[:url] + halt 400, { error: 'URL parameter is required' }.to_json unless url + + title = UrlTitleExtractorService.extract_title(url) + + if title + { url: url, title: title }.to_json + else + { url: url, title: nil, error: 'Could not extract title' }.to_json + end + end + + post '/api/url/extract-from-text' do + content_type :json + + request_body = request.body.read + + begin + data = JSON.parse(request_body) + text = data['text'] + + halt 400, { error: 'Text parameter is required' }.to_json unless text + + result = UrlTitleExtractorService.extract_title_from_text(text) + + if result + result.to_json + else + { found: false }.to_json + end + rescue JSON::ParserError + halt 400, { error: 'Invalid JSON format' }.to_json + end + end + end +end diff --git a/app/routes/users_routes.rb b/app/routes/users_routes.rb index 63539cb..9d0bc9f 100644 --- a/app/routes/users_routes.rb +++ b/app/routes/users_routes.rb @@ -5,7 +5,7 @@ module Sinatra user = current_user if user - user.to_json(only: %i[id email appearance language timezone avatar_image]) + user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id task_summary_enabled task_summary_frequency]) else halt 404, { error: 'Profile not found.' }.to_json end @@ -29,13 +29,140 @@ module Sinatra allowed_params[:language] = request_payload['language'] if request_payload.key?('language') allowed_params[:timezone] = request_payload['timezone'] if request_payload.key?('timezone') allowed_params[:avatar_image] = request_payload['avatar_image'] if request_payload.key?('avatar_image') + allowed_params[:telegram_bot_token] = request_payload['telegram_bot_token'] if request_payload.key?('telegram_bot_token') if user.update(allowed_params) - user.to_json(only: %i[id email appearance language timezone avatar_image]) + user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id]) else status 400 { error: 'Failed to update profile.', details: user.errors.full_messages }.to_json end end + + post '/api/profile/task-summary/toggle' do + content_type :json + + user = current_user + halt 404, { error: 'User not found.' }.to_json unless user + + # Toggle the task_summary_enabled flag + enabled = !user.task_summary_enabled + + if user.update(task_summary_enabled: enabled) + # If enabling, send a test summary to confirm it works + if enabled && user.telegram_bot_token && user.telegram_chat_id + begin + success = TaskSummaryService.send_summary_to_user(user.id) + + if success + { + success: true, + enabled: enabled, + message: 'Task summary notifications have been enabled and a test message was sent to your Telegram.' + }.to_json + else + user.update(task_summary_enabled: false) + halt 400, { + error: 'Failed to send test message to Telegram. Please check your Telegram bot configuration.' + }.to_json + end + rescue => e + user.update(task_summary_enabled: false) + halt 400, { + error: 'Error sending test message to Telegram.', + details: e.message + }.to_json + end + else + { + success: true, + enabled: enabled, + message: enabled ? 'Task summary notifications have been enabled.' : 'Task summary notifications have been disabled.' + }.to_json + end + else + halt 400, { + error: 'Failed to update task summary settings.', + details: user.errors.full_messages + }.to_json + end + end + + post '/api/profile/task-summary/frequency' do + content_type :json + + begin + request_payload = JSON.parse(request.body.read) + rescue JSON::ParserError + halt 400, { error: 'Invalid JSON format.' }.to_json + end + + frequency = request_payload['frequency'] + halt 400, { error: 'Frequency is required.' }.to_json unless frequency + + # Validate frequency value + valid_frequencies = User::TASK_SUMMARY_FREQUENCIES + halt 400, { error: 'Invalid frequency value.' }.to_json unless valid_frequencies.include?(frequency) + + user = current_user + halt 404, { error: 'User not found.' }.to_json unless user + + if user.update(task_summary_frequency: frequency) + { + success: true, + frequency: frequency, + message: "Task summary frequency has been set to #{frequency}." + }.to_json + else + halt 400, { + error: 'Failed to update task summary frequency.', + details: user.errors.full_messages + }.to_json + end + end + + post '/api/profile/task-summary/send-now' do + content_type :json + + user = current_user + halt 404, { error: 'User not found.' }.to_json unless user + + if user.telegram_bot_token && user.telegram_chat_id + begin + success = TaskSummaryService.send_summary_to_user(user.id) + + if success + { + success: true, + message: 'Task summary was sent to your Telegram.' + }.to_json + else + halt 400, { error: 'Failed to send message to Telegram.' }.to_json + end + rescue => e + halt 400, { + error: 'Error sending message to Telegram.', + details: e.message + }.to_json + end + else + halt 400, { error: 'Telegram bot is not properly configured.' }.to_json + end + end + + get '/api/profile/task-summary/status' do + content_type :json + + user = current_user + halt 404, { error: 'User not found.' }.to_json unless user + + { + success: true, + enabled: user.task_summary_enabled, + frequency: user.task_summary_frequency, + last_run: user.task_summary_last_run, + next_run: user.task_summary_next_run + }.to_json + end end end diff --git a/app/services/task_summary_service.rb b/app/services/task_summary_service.rb new file mode 100644 index 0000000..994ee1a --- /dev/null +++ b/app/services/task_summary_service.rb @@ -0,0 +1,288 @@ +# app/services/task_summary_service.rb +require 'yaml' + +class TaskSummaryService + # Helper method to escape special characters for MarkdownV2 + def self.escape_markdown(text) + # Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.! + text.to_s.gsub(/([_*\[\]()~`>#+\-=|{}.!])/, '\\\\\1') + end + + def self.generate_summary_for_user(user_id) + user = User.find_by(id: user_id) + return nil unless user + + # Get today's tasks, in progress tasks, etc. + tasks = user.tasks + + today = Date.today + due_today = tasks.where('DATE(due_date) = ?', today).where.not(status: 'done') + in_progress = tasks.where(status: 'in_progress') + completed_today = tasks.where(status: 'done').where('DATE(updated_at) = ?', today) + + # Generate summary message + message = "📋 *Today's Task Summary*\n\n" + + # Add a header divider + message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + + # Start Today's Plan section + message += "✏️ *Today's Plan*\n\n" + + # Add due today tasks to Today's Plan + # Add due today tasks to Today's Plan + if due_today.any? + message += "🚀 *Tasks Due Today:*\n" + due_today.order(:name).each_with_index do |task, index| + priority_emoji = + case task.priority + when 'high' then '🔴' + when 'medium' then '🟠' + when 'low' then '🟢' + else '⚪' + end + + # Escape special characters in task name and project name + task_name = escape_markdown(task.name) + project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : '' + + message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n" + end + message += "\n" + end + # Add in progress tasks to Today's Plan + if in_progress.any? + message += "⚙️ *In Progress Tasks:*\n" + in_progress.order(:name).each_with_index do |task, index| + priority_emoji = + case task.priority + when 'high' then '🔴' + when 'medium' then '🟠' + when 'low' then '🟢' + else '⚪' + end + + # Escape special characters in task name and project name + task_name = escape_markdown(task.name) + project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : '' + + message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n" + end + message += "\n" + end + # Add suggested tasks (not done, not in due today or in progress) + suggested_task_ids = due_today.pluck(:id) + in_progress.pluck(:id) + + # Get tasks in expiring projects - same logic as Task.compute_metrics + tasks_in_expiring_projects = tasks + .where.not(status: 'done') + .where.not(id: suggested_task_ids) + .joins(:project) + .where('projects.due_date_at >= ?', today) + .where(projects: { active: true }) # Only active projects + .order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC')) + + # Get tasks not assigned to projects - same logic as Task.compute_metrics + tasks_without_projects = tasks + .where.not(status: 'done') + .where.not(id: suggested_task_ids) + .where(project_id: nil, status: 'not_started') + .order(priority: :desc) + + # Combine both sets of tasks + combined_tasks = (tasks_in_expiring_projects + tasks_without_projects) + + # Sort using same logic as Task.sort_suggested_tasks + suggested_tasks = combined_tasks.sort_by do |task| + # Parse or default the task due date + task_due_date = if task.due_date.is_a?(String) + Date.parse(task.due_date) + else + task.due_date || Date.new(9999, 12, 31) + end + + # Parse or default the project due date + project_due_date = if task.project&.due_date_at.is_a?(String) + Date.parse(task&.project&.due_date_at) + else + task.project&.due_date_at || Date.new(9999, 12, 31) + end + + # Priority in descending order (sorted values should be negative for sort_by) + priority_value = -Task.priorities.fetch(task.priority, -1) + + # Determine sorting flags based on various criteria + is_high_priority_proj_with_due_date = task.priority == 'high' && task.project&.due_date_at ? 0 : 1 + is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1 + is_high_priority = task.priority == 'high' && !task.due_date && !task.project&.due_date_at ? 0 : 1 + + is_medium_priority_proj_with_due_date = task.priority == 'medium' && task.project&.due_date_at ? 0 : 1 + is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1 + is_medium_priority = task.priority == 'medium' && !task.due_date && !task.project&.due_date_at ? 0 : 1 + + is_low_priority_proj_with_due_date = task.priority == 'low' && task.project&.due_date_at ? 0 : 1 + is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1 + is_low_priority = task.priority == 'low' && !task.due_date && !task.project&.due_date_at ? 0 : 1 + + # Primary sorting criteria - same as Task.sort_suggested_tasks + [ + is_high_priority_proj_with_due_date, + is_high_priority_with_due_date, + is_high_priority, + + is_medium_priority_proj_with_due_date, + is_medium_priority_with_due_date, + is_medium_priority, + + is_low_priority_proj_with_due_date, + is_low_priority_with_due_date, + is_low_priority, + + task_due_date, + project_due_date, + priority_value + ] + end.first(5) + + if suggested_tasks.any? + message += "💡 *Suggested Tasks \\(Top 3\\):*\n" + # Only display the top 3 suggested tasks + suggested_tasks.first(5).each_with_index do |task, index| + priority_emoji = + case task.priority + when 'high' then '🔴' + when 'medium' then '🟠' + when 'low' then '🟢' + else '⚪' + end + + # Escape special characters in task name and project name + task_name = escape_markdown(task.name) + project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : '' + due_date = task.due_date ? " \\(Due: #{escape_markdown(task.due_date.strftime('%b %d'))}\\)" : '' + + message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}#{due_date}\n" + end + message += "\n" + end + + # Add a section divider + message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + + # Add completed tasks for today if any + if completed_today.any? + message += "✅ *Completed Today:*\n" + completed_today.order(updated_at: :desc).each_with_index do |task, index| + # Escape special characters in task name and project name + task_name = escape_markdown(task.name) + project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : '' + + message += "#{index + 1}\\. #{task_name}#{project_info}\n" + end + message += "\n" + end + + # Add inbox count if available + inbox_items_count = user.inbox_items.where(status: 'added').count + if inbox_items_count > 0 + message += "*Inbox:*\n" + message += "• You have #{inbox_items_count} item\\(s\\) in your inbox to process\\.\n\n" + end + + # Add a section divider + message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + # Add a motivational note from the YAML file + begin + quotes_file = Rails.root.join('config', 'quotes.yml') + quotes_data = YAML.load_file(quotes_file)['quotes'] + + message += "💪 *Today's Motivation:*\n" + quote = quotes_data.sample + # Escape special characters in the quote + message += escape_markdown(quote) + rescue StandardError => e + # Fallback to default quotes if there's an issue loading from YAML + default_quotes = [ + 'Focus on progress, not perfection.', + 'One task at a time leads to great accomplishments.', + "Today's effort is tomorrow's success.", + 'Small steps every day lead to big results.' + ] + + message += "💪 *Today's Motivation:*\n" + quote = default_quotes.sample + # Escape special characters in the quote + message += escape_markdown(quote) + end + + message + end + + def self.send_summary_to_user(user_id) + user = User.find_by(id: user_id) + return false unless user && user.telegram_bot_token && user.telegram_chat_id + + summary = generate_summary_for_user(user_id) + return false unless summary + + # Send the message via Telegram + begin + TelegramPoller.instance.send_telegram_message( + user.telegram_bot_token, + user.telegram_chat_id, + summary + ) + + # Update the last run time and calculate the next run time + now = Time.now + next_run = calculate_next_run_time(user, now) + + # Update the user's tracking fields + user.update( + task_summary_last_run: now, + task_summary_next_run: next_run + ) + + true + rescue StandardError => e + puts "Error sending task summary to user #{user_id}: #{e.message}" + false + end + end + + # Calculate when the next task summary should run based on frequency + def self.calculate_next_run_time(user, from_time = Time.now) + case user.task_summary_frequency + when 'daily' + # Next day at 7 AM + from_time.tomorrow.change(hour: 7, min: 0, sec: 0) + when 'weekdays' + # If it's Friday, next is Monday, otherwise next day (if it's a weekday) + days_until_next_weekday = + if from_time.wday == 5 # Friday + 3 # Next Monday + elsif from_time.wday == 6 # Saturday + 2 # Next Monday + else + 1 # Next day + end + from_time.advance(days: days_until_next_weekday).change(hour: 7, min: 0, sec: 0) + when 'weekly' + # Next week same day, or next Monday if we're being specific + from_time.advance(days: 7).change(hour: 7, min: 0, sec: 0) + when '1h' + from_time + 1.hour + when '2h' + from_time + 2.hours + when '4h' + from_time + 4.hours + when '8h' + from_time + 8.hours + when '12h' + from_time + 12.hours + else + # Default to daily at 7 AM + from_time.tomorrow.change(hour: 7, min: 0, sec: 0) + end + end +end diff --git a/app/services/url_title_extractor_service.rb b/app/services/url_title_extractor_service.rb new file mode 100644 index 0000000..244a902 --- /dev/null +++ b/app/services/url_title_extractor_service.rb @@ -0,0 +1,71 @@ +require 'net/http' +require 'uri' +require 'nokogiri' + +class UrlTitleExtractorService + MAX_BYTES = 50_000 + TIMEOUT = 5 + USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + + def self.url?(text) + url_regex = %r{^(https?://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$}i + text.strip.match?(url_regex) + end + + def self.extract_title(url) + url = "http://#{url}" unless url.start_with?('http://') || url.start_with?('https://') + + begin + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + + http.open_timeout = TIMEOUT + http.read_timeout = TIMEOUT + + if uri.scheme == 'https' + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + request = Net::HTTP::Get.new(uri.request_uri) + request['User-Agent'] = USER_AGENT + request['Accept'] = 'text/html' + request['Range'] = "bytes=0-#{MAX_BYTES}" + + response = http.request(request) + + if response.is_a?(Net::HTTPRedirection) + redirect_url = response['location'] + return extract_title(redirect_url) + end + + if response.code.to_i.between?(200, 299) && response.body + html = Nokogiri::HTML(response.body) + + title = html.at_css('title')&.text&.strip + return title if title && !title.empty? + + og_title = html.at_css('meta[property="og:title"]')&.attributes&.[]('content')&.value&.strip + return og_title if og_title && !og_title.empty? + + twitter_title = html.at_css('meta[name="twitter:title"]')&.attributes&.[]('content')&.value&.strip + return twitter_title if twitter_title && !twitter_title.empty? + end + + nil + rescue StandardError => e + puts "Error extracting title from URL: #{e.message}" + nil + end + end + + def self.extract_title_from_text(text) + text.split(/\s+/).each do |word| + if url?(word) + title = extract_title(word) + return { url: word, title: title } if title + end + end + nil + end +end diff --git a/config/initializers/scheduler.rb b/config/initializers/scheduler.rb new file mode 100644 index 0000000..f37bcaa --- /dev/null +++ b/config/initializers/scheduler.rb @@ -0,0 +1,184 @@ +# config/initializers/scheduler.rb +require 'rufus-scheduler' +require_relative '../../app/services/task_summary_service' + +# Helper method to update user's summary tracking fields +def update_summary_tracking(user, next_time) + user.update( + task_summary_last_run: Time.now, + task_summary_next_run: next_time + ) +end + +# Don't schedule in test environment or when reloading in development +if ENV['RACK_ENV'] != 'test' && ENV['DISABLE_SCHEDULER'] != 'true' + scheduler = Rufus::Scheduler.singleton + + # Daily schedule at 7 AM (for users with daily frequency) + daily_job = scheduler.cron '0 7 * * *' do + puts "Running scheduled task: Daily task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: 'daily') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + # Calculate next run time - tomorrow at 7 AM + next_run = Time.now.tomorrow.change(hour: 7, min: 0, sec: 0) + update_summary_tracking(user, next_run) + puts "Sent daily summary to user #{user.id}" + rescue => e + puts "Error sending daily summary to user #{user.id}: #{e.message}" + end + end + end + + # Weekdays schedule at 7 AM (Monday through Friday) + weekday_job = scheduler.cron '0 7 * * 1-5' do + puts "Running scheduled task: Weekday task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: 'weekdays') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + # Calculate next run time - next weekday at 7 AM + current_day = Time.now.wday + days_until_next_weekday = current_day == 5 ? 3 : 1 # If Friday, next is Monday (+3 days), otherwise next day + next_run = Time.now.advance(days: days_until_next_weekday).change(hour: 7, min: 0, sec: 0) + update_summary_tracking(user, next_run) + puts "Sent weekday summary to user #{user.id}" + rescue => e + puts "Error sending weekday summary to user #{user.id}: #{e.message}" + end + end + end + + # Weekly schedule at 7 AM on Monday + weekly_job = scheduler.cron '0 7 * * 1' do + puts "Running scheduled task: Weekly task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: 'weekly') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + # Calculate next run time - next Monday at 7 AM + next_run = Time.now.advance(days: 7).change(hour: 7, min: 0, sec: 0) + update_summary_tracking(user, next_run) + puts "Sent weekly summary to user #{user.id}" + rescue => e + puts "Error sending weekly summary to user #{user.id}: #{e.message}" + end + end + end + + # Hourly schedules for different intervals + + # Every 1 hour + hourly_job = scheduler.every '1h' do + puts "Running scheduled task: Hourly (1h) task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: '1h') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + next_run = Time.now + 1.hour + update_summary_tracking(user, next_run) + puts "Sent hourly summary to user #{user.id}" + rescue => e + puts "Error sending hourly summary to user #{user.id}: #{e.message}" + end + end + end + + # Every 2 hours + two_hourly_job = scheduler.every '2h' do + puts "Running scheduled task: 2-hour task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: '2h') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + next_run = Time.now + 2.hours + update_summary_tracking(user, next_run) + puts "Sent 2-hour summary to user #{user.id}" + rescue => e + puts "Error sending 2-hour summary to user #{user.id}: #{e.message}" + end + end + end + + # Every 4 hours + four_hourly_job = scheduler.every '4h' do + puts "Running scheduled task: 4-hour task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: '4h') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + next_run = Time.now + 4.hours + update_summary_tracking(user, next_run) + puts "Sent 4-hour summary to user #{user.id}" + rescue => e + puts "Error sending 4-hour summary to user #{user.id}: #{e.message}" + end + end + end + + # Every 8 hours + eight_hourly_job = scheduler.every '8h' do + puts "Running scheduled task: 8-hour task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: '8h') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + next_run = Time.now + 8.hours + update_summary_tracking(user, next_run) + puts "Sent 8-hour summary to user #{user.id}" + rescue => e + puts "Error sending 8-hour summary to user #{user.id}: #{e.message}" + end + end + end + + # Every 12 hours + twelve_hourly_job = scheduler.every '12h' do + puts "Running scheduled task: 12-hour task summary" + + User.where.not(telegram_bot_token: [nil, '']) + .where.not(telegram_chat_id: [nil, '']) + .where(task_summary_enabled: true) + .where(task_summary_frequency: '12h') + .each do |user| + begin + TaskSummaryService.send_summary_to_user(user.id) + next_run = Time.now + 12.hours + update_summary_tracking(user, next_run) + puts "Sent 12-hour summary to user #{user.id}" + rescue => e + puts "Error sending 12-hour summary to user #{user.id}: #{e.message}" + end + end + end +end + diff --git a/config/initializers/telegram_initializer.rb b/config/initializers/telegram_initializer.rb new file mode 100644 index 0000000..3953812 --- /dev/null +++ b/config/initializers/telegram_initializer.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env ruby +# config/initializers/telegram_initializer.rb +require_relative '../../app/routes/telegram_poller' +require_relative '../../app/models/user' + +# Create a method to be called after database connection is established +def initialize_telegram_polling + if ENV['RACK_ENV'] != 'test' && ENV['DISABLE_TELEGRAM'] != 'true' + puts "Initializing Telegram polling for configured users..." + + # Get singleton instance of the poller + poller = TelegramPoller.instance + + # Make sure we have a database connection + begin + ActiveRecord::Base.connection_pool.with_connection do |connection| + # Check if the users table exists + if connection.table_exists?('users') + begin + # Find users with configured Telegram tokens + users_with_telegram = User.where.not(telegram_bot_token: [nil, '']) + + if users_with_telegram.any? + puts "Found #{users_with_telegram.count} users with Telegram configuration" + + # Add each user to the polling list + users_with_telegram.each do |user| + puts "Starting Telegram polling for user #{user.id}" + poller.add_user(user) + end + + puts "Telegram polling initialized successfully" + else + puts "No users with Telegram configuration found" + end + rescue => e + puts "Error initializing Telegram polling: #{e.message}" + puts e.backtrace.join("\n") + end + else + puts "Users table doesn't exist yet, skipping Telegram initialization" + end + end + rescue => e + puts "Database connection not available for Telegram initialization: #{e.message}" + puts "Telegram polling will be initialized later when the database is available." + end + end +end + +# Don't run the initializer here - we'll hook it into the Sinatra app after ActiveRecord is initialized \ No newline at end of file diff --git a/config/quotes.yml b/config/quotes.yml new file mode 100644 index 0000000..74dced7 --- /dev/null +++ b/config/quotes.yml @@ -0,0 +1,22 @@ +quotes: + - "Believe you can and you're halfway there." + - "The only way to do great work is to love what you do." + - "Success is not final, failure is not fatal: It is the courage to continue that counts." + - "It always seems impossible until it's done." + - "Your time is limited, don't waste it living someone else's life." + - "The future belongs to those who believe in the beauty of their dreams." + - "Don't watch the clock; do what it does. Keep going." + - "Quality is not an act, it is a habit." + - "The only limit to our realization of tomorrow is our doubts of today." + - "Act as if what you do makes a difference. It does." + - "The best way to predict the future is to create it." + - "Success is walking from failure to failure with no loss of enthusiasm." + - "You are never too old to set another goal or to dream a new dream." + - "The secret of getting ahead is getting started." + - "Don't let yesterday take up too much of today." + - "You don't have to be great to start, but you have to start to be great." + - "Focus on progress, not perfection." + - "One task at a time leads to great accomplishments." + - "Today's effort is tomorrow's success." + - "Small steps every day lead to big results." + diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..96891c8 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1746192262 rack.session W3JvYS7y9hXUKdr6WsdMlrZaPVjCH0GAirPw%2Fmurx6MIJQm8e%2FHnISYGeYeEFrYXHvM52EbBaEatcQz7Fvd4%2F9VMWQvT5WrVrf1w%2F4Lb7abdHbwYJkQiK7o0L4rL%2Bj88ILPQ7ZY4fPqvl%2BFMeGsqO2VGJpwhE%2BU2XCKqBhFS81ejdBT%2BAHlWTzOeEzJ7ElC3Vo%2FBME%2BTEMddkC7lvkYQoWw1BoiRnLTrniQx1kWAb5pdBFf16RsuEBo9Z%2BSw1YryDdPUWfJnVLXT9szA9f45o9D%2Fsqo36VuniodyaDSS--xk78gHip2BjCI4ab--xgh8%2BzQm0bE%2BPLvdjTHLwg%3D%3D diff --git a/db/migrate/20250414134722_create_inbox_items.rb b/db/migrate/20250414134722_create_inbox_items.rb new file mode 100644 index 0000000..009477b --- /dev/null +++ b/db/migrate/20250414134722_create_inbox_items.rb @@ -0,0 +1,11 @@ +class CreateInboxItems < ActiveRecord::Migration[7.1] + def change + create_table :inbox_items do |t| + t.string :content, null: false + t.references :user, null: false, foreign_key: true + t.string :status, default: 'added' + t.string :source, default: 'tududi' + t.timestamps + end + end +end diff --git a/db/migrate/20250414150330_add_telegram_token_to_users.rb b/db/migrate/20250414150330_add_telegram_token_to_users.rb new file mode 100644 index 0000000..e2f930c --- /dev/null +++ b/db/migrate/20250414150330_add_telegram_token_to_users.rb @@ -0,0 +1,6 @@ +class AddTelegramTokenToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :telegram_bot_token, :string + add_column :users, :telegram_chat_id, :string + end +end diff --git a/db/migrate/20250416231240_add_task_summary_to_users.rb b/db/migrate/20250416231240_add_task_summary_to_users.rb new file mode 100644 index 0000000..798375b --- /dev/null +++ b/db/migrate/20250416231240_add_task_summary_to_users.rb @@ -0,0 +1,7 @@ +class AddTaskSummaryToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :task_summary_enabled, :boolean, default: false + add_column :users, :task_summary_frequency, :string, default: 'daily' + end +end + diff --git a/db/migrate/20250416235420_add_task_summary_run_tracking_to_users.rb b/db/migrate/20250416235420_add_task_summary_run_tracking_to_users.rb new file mode 100644 index 0000000..2cc9878 --- /dev/null +++ b/db/migrate/20250416235420_add_task_summary_run_tracking_to_users.rb @@ -0,0 +1,7 @@ +class AddTaskSummaryRunTrackingToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :task_summary_last_run, :datetime + add_column :users, :task_summary_next_run, :datetime + end +end + diff --git a/db/schema.rb b/db/schema.rb index 560cbd9..a261c6d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do +ActiveRecord::Schema[7.1].define(version: 2025_04_16_235420) do create_table "areas", force: :cascade do |t| t.string "name" t.integer "user_id", null: false @@ -20,6 +20,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do t.index ["user_id"], name: "index_areas_on_user_id" end + create_table "inbox_items", force: :cascade do |t| + t.string "content", null: false + t.integer "user_id", null: false + t.string "status", default: "added" + t.string "source", default: "tududi" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_inbox_items_on_user_id" + end + create_table "notes", force: :cascade do |t| t.text "content" t.integer "user_id", null: false @@ -87,7 +97,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do t.integer "priority" t.text "note" t.integer "status", default: 0 + t.string "recurrence_type", default: "none" + t.integer "recurrence_interval" + t.datetime "recurrence_end_date" + t.datetime "last_generated_date" + t.index ["last_generated_date"], name: "index_tasks_on_last_generated_date" t.index ["project_id"], name: "index_tasks_on_project_id" + t.index ["recurrence_type"], name: "index_tasks_on_recurrence_type" t.index ["user_id"], name: "index_tasks_on_user_id" end @@ -101,9 +117,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do t.string "language", default: "en", null: false t.string "timezone", default: "UTC", null: false t.string "avatar_image" + t.string "telegram_bot_token" + t.string "telegram_chat_id" + t.boolean "task_summary_enabled", default: false + t.string "task_summary_frequency", default: "daily" + t.datetime "task_summary_last_run" + t.datetime "task_summary_next_run" end add_foreign_key "areas", "users" + add_foreign_key "inbox_items", "users" add_foreign_key "notes", "projects" add_foreign_key "notes", "users", on_delete: :cascade add_foreign_key "projects", "areas", on_delete: :cascade diff --git a/index.html b/index.html index 7bbd4e0..b7d5ff4 100644 --- a/index.html +++ b/index.html @@ -550,7 +550,7 @@ } - +