diff --git a/backend/migrations/20251118000001-add-recurring-to-views.js b/backend/migrations/20251118000001-add-recurring-to-views.js new file mode 100644 index 0000000..a64c2f1 --- /dev/null +++ b/backend/migrations/20251118000001-add-recurring-to-views.js @@ -0,0 +1,24 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'views', [ + { + name: 'recurring', + definition: { + type: Sequelize.STRING, + allowNull: true, + }, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await safeRemoveColumn(queryInterface, 'views', 'recurring'); + }, +}; diff --git a/backend/models/view.js b/backend/models/view.js index f6bd2f9..7cb4542 100644 --- a/backend/models/view.js +++ b/backend/models/view.js @@ -62,6 +62,10 @@ module.exports = (sequelize) => { this.setDataValue('tags', JSON.stringify(value)); }, }, + recurring: { + type: DataTypes.STRING, + allowNull: true, + }, is_pinned: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/routes/search.js b/backend/routes/search.js index 08fa7a5..0b4bdce 100644 --- a/backend/routes/search.js +++ b/backend/routes/search.js @@ -2,6 +2,7 @@ const express = require('express'); const { Task, Tag, Project, Area, Note, sequelize } = require('../models'); const { Op } = require('sequelize'); const moment = require('moment-timezone'); +const { serializeTasks } = require('./tasks/core/serializers'); const router = express.Router(); // Helper function to convert priority string to integer @@ -25,6 +26,7 @@ const priorityToInt = (priorityStr) => { * - priority: filter by priority (low,medium,high) * - due: filter by due date (today,tomorrow,next_week,next_month) * - tags: comma-separated list of tag names to filter by + * - recurring: filter by recurrence type (recurring,non_recurring,instances) * - limit: number of results to return (default: 20) * - offset: number of results to skip (default: 0) * - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id @@ -42,6 +44,7 @@ router.get('/', async (req, res) => { priority, due, tags: tagsParam, + recurring, limit: limitParam, offset: offsetParam, excludeSubtasks, @@ -158,11 +161,45 @@ router.get('/', async (req, res) => { Object.assign(taskConditions, dueDateCondition); } + // Add recurring filter if specified + if (recurring) { + switch (recurring) { + case 'recurring': + // Show only recurring templates (not instances) + taskConditions.recurrence_type = { [Op.ne]: 'none' }; + taskConditions.recurring_parent_id = null; + break; + case 'non_recurring': + // Show only non-recurring tasks (not templates or instances) + taskConditions[Op.or] = [ + { recurrence_type: 'none' }, + { recurrence_type: null }, + ]; + taskConditions.recurring_parent_id = null; + break; + case 'instances': + // Show only recurring instances (spawned from templates) + taskConditions.recurring_parent_id = { [Op.ne]: null }; + break; + } + } + const taskInclude = [ { model: Project, attributes: ['id', 'uid', 'name'], }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + }, ]; // Add tag filter if specified @@ -173,9 +210,17 @@ router.get('/', async (req, res) => { id: { [Op.in]: tagIds }, }, through: { attributes: [] }, - attributes: [], + attributes: ['id', 'name', 'uid'], required: true, }); + } else { + // Always include tags for display, even if not filtering + taskInclude.push({ + model: Tag, + through: { attributes: [] }, + attributes: ['id', 'name', 'uid'], + required: false, + }); } // Count total tasks if pagination is requested @@ -195,15 +240,17 @@ router.get('/', async (req, res) => { order: [['updated_at', 'DESC']], }); + // Use proper serialization to include all task data + const serializedTasks = await serializeTasks( + tasks, + req.currentUser?.timezone || 'UTC' + ); + results.push( - ...tasks.map((task) => ({ + ...serializedTasks.map((task) => ({ type: 'Task', - id: task.id, - uid: task.uid, - name: task.name, + ...task, description: task.note, - priority: task.priority, - status: task.status, })) ); } diff --git a/backend/routes/views.js b/backend/routes/views.js index 862b62c..4aaf93b 100644 --- a/backend/routes/views.js +++ b/backend/routes/views.js @@ -64,7 +64,8 @@ router.get('/:identifier', async (req, res) => { // POST /api/views - Create a new view router.post('/', async (req, res) => { try { - const { name, search_query, filters, priority, due, tags } = req.body; + const { name, search_query, filters, priority, due, tags, recurring } = + req.body; if (!name || name.trim() === '') { return res.status(400).json({ error: 'View name is required' }); @@ -78,6 +79,7 @@ router.post('/', async (req, res) => { priority: priority || null, due: due || null, tags: tags || [], + recurring: recurring || null, is_pinned: false, }); @@ -106,8 +108,16 @@ router.patch('/:identifier', async (req, res) => { return res.status(404).json({ error: 'View not found' }); } - const { name, search_query, filters, priority, due, tags, is_pinned } = - req.body; + const { + name, + search_query, + filters, + priority, + due, + tags, + recurring, + is_pinned, + } = req.body; const updates = {}; if (name !== undefined) updates.name = name.trim(); @@ -116,6 +126,7 @@ router.patch('/:identifier', async (req, res) => { if (priority !== undefined) updates.priority = priority; if (due !== undefined) updates.due = due; if (tags !== undefined) updates.tags = tags; + if (recurring !== undefined) updates.recurring = recurring; if (is_pinned !== undefined) updates.is_pinned = is_pinned; await view.update(updates); diff --git a/frontend/components/UniversalSearch/SearchMenu.tsx b/frontend/components/UniversalSearch/SearchMenu.tsx index 6329e53..aecc738 100644 --- a/frontend/components/UniversalSearch/SearchMenu.tsx +++ b/frontend/components/UniversalSearch/SearchMenu.tsx @@ -55,6 +55,12 @@ const dueOptions = [ { value: 'next_month', labelKey: 'dateIndicators.nextMonth' }, ]; +const recurringOptions = [ + { value: 'recurring', labelKey: 'search.recurringFilter.recurring' }, + { value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' }, + { value: 'instances', labelKey: 'search.recurringFilter.instances' }, +]; + const SearchMenu: React.FC = ({ searchQuery, selectedFilters, @@ -68,6 +74,9 @@ const SearchMenu: React.FC = ({ ); const [selectedDue, setSelectedDue] = useState(null); const [selectedTags, setSelectedTags] = useState([]); + const [selectedRecurring, setSelectedRecurring] = useState( + null + ); const [availableTags, setAvailableTags] = useState< Array<{ id: number; name: string }> >([]); @@ -111,6 +120,12 @@ const SearchMenu: React.FC = ({ ); }; + const handleRecurringToggle = (recurring: string) => { + setSelectedRecurring( + selectedRecurring === recurring ? null : recurring + ); + }; + const handleSaveView = async () => { if (!viewName.trim()) { setSaveError(t('search.viewNameRequired')); @@ -134,6 +149,7 @@ const SearchMenu: React.FC = ({ priority: selectedPriority || null, due: selectedDue || null, tags: selectedTags.length > 0 ? selectedTags : null, + recurring: selectedRecurring || null, }), }); @@ -300,6 +316,27 @@ const SearchMenu: React.FC = ({ parts.push(...tagsWithSeparators); } + // Add recurring filter + if (selectedRecurring) { + const recurringOption = recurringOptions.find( + (opt) => opt.value === selectedRecurring + ); + const recurringLabel = recurringOption + ? t(recurringOption.labelKey) + : selectedRecurring; + parts.push( + {t('search.thatAre') + ' '} + ); + parts.push( + + {recurringLabel} + + ); + } + if (parts.length === 0) return null; // Construct the sentence @@ -316,7 +353,8 @@ const SearchMenu: React.FC = ({ searchQuery.trim() || selectedPriority || selectedDue || - selectedTags.length > 0; + selectedTags.length > 0 || + selectedRecurring; return (
= ({ )}
+ {/* Divider */} +
+ + {/* Extras Section */} +
+
+ {t('search.extras')} +
+ + {/* Recurring Filters */} +
+
+ {t('search.recurringFilter.label')} +
+
+ {recurringOptions.map((option) => ( + + handleRecurringToggle( + option.value + ) + } + /> + ))} +
+
+
+ {/* Save as Smart View Section */} {hasActiveFilters && (
@@ -553,6 +626,7 @@ const SearchMenu: React.FC = ({ selectedPriority={selectedPriority} selectedDue={selectedDue} selectedTags={selectedTags} + selectedRecurring={selectedRecurring} onClose={onClose} />
diff --git a/frontend/components/UniversalSearch/SearchResults.tsx b/frontend/components/UniversalSearch/SearchResults.tsx index 523e023..3df8288 100644 --- a/frontend/components/UniversalSearch/SearchResults.tsx +++ b/frontend/components/UniversalSearch/SearchResults.tsx @@ -16,6 +16,7 @@ interface SearchResultsProps { selectedPriority: string | null; selectedDue: string | null; selectedTags: string[]; + selectedRecurring: string | null; onClose: () => void; } @@ -36,6 +37,7 @@ const SearchResults: React.FC = ({ selectedPriority, selectedDue, selectedTags, + selectedRecurring, onClose, }) => { const { t } = useTranslation(); @@ -50,7 +52,8 @@ const SearchResults: React.FC = ({ selectedFilters.length === 0 && !selectedPriority && !selectedDue && - selectedTags.length === 0 + selectedTags.length === 0 && + !selectedRecurring ) { setResults([]); return; @@ -64,6 +67,7 @@ const SearchResults: React.FC = ({ priority: selectedPriority || undefined, due: selectedDue || undefined, tags: selectedTags.length > 0 ? selectedTags : undefined, + recurring: selectedRecurring || undefined, }); setResults(data.results); } catch (error) { @@ -82,6 +86,7 @@ const SearchResults: React.FC = ({ selectedPriority, selectedDue, selectedTags, + selectedRecurring, ]); const getIcon = (type: string) => { diff --git a/frontend/components/ViewDetail.tsx b/frontend/components/ViewDetail.tsx index 042da15..109b484 100644 --- a/frontend/components/ViewDetail.tsx +++ b/frontend/components/ViewDetail.tsx @@ -30,6 +30,7 @@ interface View { priority: string | null; due: string | null; tags: string[]; + recurring: string | null; is_pinned: boolean; } @@ -234,6 +235,7 @@ const ViewDetail: React.FC = () => { viewData.tags && viewData.tags.length > 0 ? viewData.tags : undefined, + recurring: viewData.recurring || undefined, limit: limit, offset: currentOffset, excludeSubtasks: true, @@ -343,6 +345,14 @@ const ViewDetail: React.FC = () => { } }; + const handleTaskCompletionToggle = (updatedTask: Task) => { + setTasks((prevTasks) => + prevTasks.map((task) => + task.id === updatedTask.id ? updatedTask : task + ) + ); + }; + const getCompletionPercentage = (project: Project) => { return (project as any).completion_percentage || 0; }; @@ -650,12 +660,26 @@ const ViewDetail: React.FC = () => { )} + {view.recurring && ( +
+

+ {t('views.recurring')} +

+ + {view.recurring.replace( + /_/g, + ' ' + )} + +
+ )} {!view.filters.length && !view.search_query && !view.priority && !view.due && (!view.tags || - view.tags.length === 0) && ( + view.tags.length === 0) && + !view.recurring && (

{t( 'views.noCriteriaSet' @@ -724,8 +748,9 @@ const ViewDetail: React.FC = () => {