+ {/* 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}
+
+
+
+
+
+
+ {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 */}
{
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{" "}
- {capitalize(orderBy.split(":")[0].replace("_", " "))}
+ {t(`sort.${orderBy.split(":")[0]}`, capitalize(orderBy.split(":")[0].replace("_", " ")))}
@@ -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 */}
{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 @@
}
-
+