diff --git a/backend/routes/search.js b/backend/routes/search.js index 0b4bdce..7f49894 100644 --- a/backend/routes/search.js +++ b/backend/routes/search.js @@ -43,6 +43,7 @@ router.get('/', async (req, res) => { filters, priority, due, + defer, tags: tagsParam, recurring, limit: limitParam, @@ -121,6 +122,43 @@ router.get('/', async (req, res) => { } } + // Calculate defer until date range based on filter + let deferDateCondition = null; + if (defer) { + const now = moment().startOf('day'); + let startDate, endDate; + + switch (defer) { + case 'today': + startDate = now.clone(); + endDate = now.clone().endOf('day'); + break; + case 'tomorrow': + startDate = now.clone().add(1, 'day'); + endDate = now.clone().add(1, 'day').endOf('day'); + break; + case 'next_week': + startDate = now.clone(); + endDate = now.clone().add(7, 'days').endOf('day'); + break; + case 'next_month': + startDate = now.clone(); + endDate = now.clone().add(1, 'month').endOf('day'); + break; + } + + if (startDate && endDate) { + deferDateCondition = { + defer_until: { + [Op.between]: [ + startDate.toISOString(), + endDate.toISOString(), + ], + }, + }; + } + } + // Search Tasks if (filterTypes.includes('Task')) { const taskConditions = { @@ -161,6 +199,11 @@ router.get('/', async (req, res) => { Object.assign(taskConditions, dueDateCondition); } + // Add defer until filter if specified + if (deferDateCondition) { + Object.assign(taskConditions, deferDateCondition); + } + // Add recurring filter if specified if (recurring) { switch (recurring) { diff --git a/backend/seeders/dev-seeder.js b/backend/seeders/dev-seeder.js index 43352a0..1a52a8a 100644 --- a/backend/seeders/dev-seeder.js +++ b/backend/seeders/dev-seeder.js @@ -10,6 +10,7 @@ const { Role, } = require('../models'); const bcrypt = require('bcrypt'); +const { faker } = require('@faker-js/faker'); const { createMassiveTaskData } = require('./massive-tasks'); async function seedDatabase() { @@ -312,6 +313,33 @@ async function seedDatabase() { tasks.push(backlogTask); } + // Create tasks marked for today (today = true) to test pagination + console.log('📅 Creating tasks marked for today...'); + const todayMarkedTasks = Array.from({ length: 30 }, (_, i) => ({ + name: `${faker.lorem.sentence({ min: 3, max: 6 })} #${i + 1}`, + description: faker.lorem.paragraph(), + note: + Math.random() < 0.3 + ? `${faker.lorem.sentence()}\n\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}` + : null, + priority: Math.floor(Math.random() * 3), + status: Math.floor(Math.random() * 3), // 0, 1, or 2 + user_id: testUser.id, + today: true, // Mark for today + project_id: + Math.random() < 0.3 + ? projects[Math.floor(Math.random() * projects.length)].id + : null, + })); + + for (const taskData of todayMarkedTasks) { + const task = await Task.create(taskData); + tasks.push(task); + } + console.log( + ` ✅ Created ${todayMarkedTasks.length} tasks marked for today\n` + ); + // Create tasks due today for realistic "Due Today" section console.log('📅 Creating tasks due today...'); const todayTaskNames = [ @@ -350,7 +378,6 @@ async function seedDatabase() { // Create subtasks for some tasks console.log('📋 Creating subtasks for parent tasks...'); - const { faker } = require('@faker-js/faker'); // Select 15-20 random tasks to have subtasks const parentTaskIndices = []; diff --git a/frontend/components/Project/BannerEditModal.tsx b/frontend/components/Project/BannerEditModal.tsx new file mode 100644 index 0000000..0daa625 --- /dev/null +++ b/frontend/components/Project/BannerEditModal.tsx @@ -0,0 +1,363 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { getPresetBanners, PresetBanner } from '../../utils/bannersService'; +import { getApiPath } from '../../config/paths'; + +interface BannerEditModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (imageUrl: string) => Promise; + currentImageUrl?: string; +} + +const BannerEditModal: React.FC = ({ + isOpen, + onClose, + onSave, + currentImageUrl = '', +}) => { + const { t } = useTranslation(); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(currentImageUrl); + const [isUploading, setIsUploading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [presetBanners] = useState(getPresetBanners()); + const fileInputRef = useRef(null); + const modalRef = useRef(null); + const [isClosing, setIsClosing] = useState(false); + + useEffect(() => { + setImagePreview(currentImageUrl); + }, [currentImageUrl, isOpen]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + const handlePresetBannerSelect = (banner: PresetBanner) => { + setImageFile(null); + setImagePreview(banner.url); + setError(null); + }; + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const maxSizeBytes = 10 * 1024 * 1024; + if (file.size > maxSizeBytes) { + setError( + t( + 'errors.projectImageTooLarge', + 'Image is too large. Please choose a file under 10MB.' + ) + ); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + return; + } + + setImageFile(file); + + const reader = new FileReader(); + reader.onload = (ev) => { + setImagePreview(ev.target?.result as string); + }; + reader.readAsDataURL(file); + setError(null); + }; + + const handleImageUpload = async (): Promise => { + if (!imageFile) return null; + + setIsUploading(true); + try { + const formData = new FormData(); + formData.append('image', imageFile); + + const response = await fetch(getApiPath('upload/project-image'), { + method: 'POST', + credentials: 'include', + body: formData, + }); + + if (!response.ok) { + let serverMessage = 'Failed to upload image'; + try { + const errData = await response.json(); + if (errData?.error) serverMessage = errData.error; + } catch { + // ignore parse errors + } + throw new Error(serverMessage); + } + + const result = await response.json(); + if (result?.imageUrl) { + return result.imageUrl; + } + + throw new Error('Image URL missing from upload response'); + } catch (error) { + console.error('Error uploading image:', error); + setError( + t( + 'errors.projectImageUpload', + 'Failed to upload image. Please try a smaller file or a different format.' + ) + ); + return null; + } finally { + setIsUploading(false); + } + }; + + const handleRemoveImage = () => { + setImageFile(null); + setImagePreview(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSubmit = async () => { + setIsSaving(true); + setError(null); + + try { + let imageUrl = imagePreview; + + // Upload image if a new one was selected + if (imageFile) { + const uploadedImageUrl = await handleImageUpload(); + if (uploadedImageUrl) { + imageUrl = uploadedImageUrl; + } else { + setIsSaving(false); + return; + } + } + + await onSave(imageUrl); + handleClose(); + } catch (error) { + console.error('Error saving banner:', error); + setError(t('errors.bannerSaveFailed', 'Failed to save banner')); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + setImageFile(null); + setError(null); + }, 300); + }; + + if (!isOpen) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) { + handleClose(); + } + }} + > +
+
+
+
+
+
+

+ {t( + 'project.editBanner', + 'Edit Project Banner' + )} +

+ + {error && ( +
+

+ {error} +

+
+ )} + + {imagePreview && ( +
+

+ {t( + 'project.currentBanner', + 'Current Banner' + )} +

+
+ Banner preview + +
+
+ )} + +
+

+ {t( + 'project.choosePreset', + 'Choose a preset banner:' + )} +

+
+ {presetBanners.map((banner) => ( + + ))} +
+
+ +
+

+ {t( + 'project.orUploadOwn', + 'Or upload your own:' + )} +

+ + +

+ {t( + 'project.uploadImageHint', + 'Upload an image for your project (max 10MB)' + )} +

+
+
+
+
+ +
+ + +
+
+
+
+
, + document.body + ); +}; + +export default BannerEditModal; diff --git a/frontend/components/Project/ProjectBanner.tsx b/frontend/components/Project/ProjectBanner.tsx index cc87b0d..bc59455 100644 --- a/frontend/components/Project/ProjectBanner.tsx +++ b/frontend/components/Project/ProjectBanner.tsx @@ -5,12 +5,17 @@ import { PencilSquareIcon, TrashIcon, ShareIcon, + CameraIcon, } from '@heroicons/react/24/outline'; import BannerBadge from '../Shared/BannerBadge'; import { Project } from '../../entities/Project'; import { Area } from '../../entities/Area'; import { useNavigate } from 'react-router-dom'; import { TFunction } from 'i18next'; +import { + getCreatorFromBannerUrl, + isPresetBanner, +} from '../../utils/bannersService'; interface ProjectBannerProps { project: Project; @@ -19,6 +24,7 @@ interface ProjectBannerProps { getStateIcon: (state: string) => React.ReactNode; onDeleteClick: () => void; editButtonRef: RefObject; + onEditBannerClick?: () => void; } const ProjectBanner: React.FC = ({ @@ -28,8 +34,13 @@ const ProjectBanner: React.FC = ({ getStateIcon, onDeleteClick, editButtonRef, + onEditBannerClick, }) => { const navigate = useNavigate(); + const creatorName = + project.image_url && isPresetBanner(project.image_url) + ? getCreatorFromBannerUrl(project.image_url) + : null; return (
@@ -38,10 +49,16 @@ const ProjectBanner: React.FC = ({ {project.name} ) : ( -
+
+ )} + + {creatorName && ( +
+ Photo by {creatorName} +
)}
@@ -148,6 +165,20 @@ const ProjectBanner: React.FC = ({
+ {onEditBannerClick && ( + + )} -
-
- ) : null} - - - -

- {t( - 'project.uploadImageHint', - 'Upload an image for your project (max 10MB)' - )} -

- - )} - {expandedSections.priority && (

@@ -901,28 +708,6 @@ const ProjectModal: React.FC = ({ )} - {/* Project Image Toggle */} - - {/* Priority Toggle */}

diff --git a/frontend/components/Task/TasksToday.tsx b/frontend/components/Task/TasksToday.tsx index 85d191c..fc2920d 100644 --- a/frontend/components/Task/TasksToday.tsx +++ b/frontend/components/Task/TasksToday.tsx @@ -15,6 +15,7 @@ import { ChevronRightIcon, Cog6ToothIcon, CalendarDaysIcon, + QueueListIcon, } from '@heroicons/react/24/outline'; import { fetchTasks, @@ -120,6 +121,21 @@ const TasksToday: React.FC = () => { tasks_completed_today: [], }); + // Pagination state for Today Plan tasks + const [pagination, setPagination] = useState({ + total: 0, + limit: 20, + offset: 0, + hasMore: false, + }); + + // Client-side pagination for Due Today tasks (since backend returns all) + const [dueTodayDisplayLimit, setDueTodayDisplayLimit] = useState(20); + + // Client-side pagination for Completed Today tasks (since backend returns all) + const [completedTodayDisplayLimit, setCompletedTodayDisplayLimit] = + useState(20); + // Helper function to get completion trend vs average const getCompletionTrend = () => { const todayCount = metrics.tasks_completed_today.length; @@ -216,7 +232,9 @@ const TasksToday: React.FC = () => { setIsLoading(true); try { - const result = await fetchTasks('?type=today'); + const result = await fetchTasks( + `?type=today&limit=20&offset=0` + ); if (isMounted.current) { setMetrics({ ...result.metrics, @@ -228,6 +246,12 @@ const TasksToday: React.FC = () => { tasks_completed_today: result.tasks_completed_today || [], } as any); + + // Update pagination state if pagination metadata is present + if (result.pagination) { + setPagination(result.pagination); + } + useStore.getState().tasksStore.setTasks(result.tasks); setIsError(false); } @@ -744,6 +768,101 @@ const TasksToday: React.FC = () => { [handleTaskUpdate] ); + // Load more tasks (pagination) + const handleLoadMore = useCallback( + async (all: boolean = false) => { + if (!isMounted.current || isLoading) return; + if (!all && !pagination.hasMore) return; + + setIsLoading(true); + try { + let limit: number, offset: number; + if (all) { + // Load all remaining tasks + limit = pagination.total > 0 ? pagination.total : 10000; + offset = 0; + } else { + // Load next page + limit = pagination.limit; + offset = pagination.offset + pagination.limit; + } + + const result = await fetchTasks( + `?type=today&limit=${limit}&offset=${offset}` + ); + + if (isMounted.current) { + if (all) { + // Replace all tasks when loading all + setMetrics({ + ...result.metrics, + tasks_in_progress: result.tasks_in_progress || [], + tasks_due_today: result.tasks_due_today || [], + today_plan_tasks: result.tasks || [], + suggested_tasks: result.suggested_tasks || [], + tasks_completed_today: + result.tasks_completed_today || [], + } as any); + + useStore.getState().tasksStore.setTasks(result.tasks); + } else { + // Append new tasks to existing ones + setMetrics((prevMetrics) => ({ + ...result.metrics, + tasks_in_progress: [ + ...(prevMetrics.tasks_in_progress || []), + ...(result.tasks_in_progress || []), + ], + tasks_due_today: [ + ...(prevMetrics.tasks_due_today || []), + ...(result.tasks_due_today || []), + ], + today_plan_tasks: [ + ...(prevMetrics.today_plan_tasks || []), + ...(result.tasks || []), + ], + suggested_tasks: [ + ...(prevMetrics.suggested_tasks || []), + ...(result.suggested_tasks || []), + ], + tasks_completed_today: [ + ...(prevMetrics.tasks_completed_today || []), + ...(result.tasks_completed_today || []), + ], + })); + + // Append tasks to store + const currentTasks = + useStore.getState().tasksStore.tasks; + useStore + .getState() + .tasksStore.setTasks([ + ...currentTasks, + ...result.tasks, + ]); + } + + // Update pagination state + if (result.pagination) { + setPagination(result.pagination); + } + + // If loading all, mark hasMore as false + if (all) { + setPagination((prev) => ({ ...prev, hasMore: false })); + } + } + } catch (error) { + console.error('Error loading more tasks:', error); + } finally { + if (isMounted.current) { + setIsLoading(false); + } + } + }, + [pagination, isLoading] + ); + // Calculate today's progress for the progress bar const getTodayProgress = () => { const todayTasks = metrics.today_plan_tasks || []; @@ -1091,6 +1210,70 @@ const TasksToday: React.FC = () => { onTaskCompletionToggle={handleTaskCompletionToggle} /> + {/* Load More Buttons for Today Plan Tasks */} + {pagination.hasMore && ( +
+ + +
+ )} + + {/* Pagination info for Today Plan tasks */} + {(metrics.today_plan_tasks || []).length > 0 && ( +
+ {t( + 'tasks.showingItems', + 'Showing {{current}} of {{total}} items', + { + current: (metrics.today_plan_tasks || []) + .length, + total: pagination.total, + } + )} +
+ )} + {/* Suggested Tasks - Separate setting */} {!isSettingsLoaded ? ( // Invisible placeholder for suggestions @@ -1145,7 +1328,10 @@ const TasksToday: React.FC = () => { {t('tasks.dueToday')} { handleTaskCompletionToggle } /> + + {/* Load More Buttons for Due Today Tasks */} + {dueTodayDisplayLimit < + metrics.tasks_due_today.length && ( +
+ + +
+ )} + + {/* Pagination info for Due Today tasks */} +
+ {t( + 'tasks.showingItems', + 'Showing {{current}} of {{total}} items', + { + current: Math.min( + dueTodayDisplayLimit, + metrics.tasks_due_today.length + ), + total: metrics.tasks_due_today.length, + } + )} +
)} @@ -1184,14 +1413,71 @@ const TasksToday: React.FC = () => { {!isCompletedCollapsed && (completedToday.length > 0 ? ( - + <> + + + {/* Load More Buttons for Completed Today Tasks */} + {completedTodayDisplayLimit < + completedToday.length && ( +
+ + +
+ )} + + {/* Pagination info for Completed Today tasks */} +
+ {t( + 'tasks.showingItems', + 'Showing {{current}} of {{total}} items', + { + current: Math.min( + completedTodayDisplayLimit, + completedToday.length + ), + total: completedToday.length, + } + )} +
+ ) : (

{t( diff --git a/frontend/components/UniversalSearch/SearchMenu.tsx b/frontend/components/UniversalSearch/SearchMenu.tsx index aecc738..5af36f9 100644 --- a/frontend/components/UniversalSearch/SearchMenu.tsx +++ b/frontend/components/UniversalSearch/SearchMenu.tsx @@ -55,6 +55,13 @@ const dueOptions = [ { value: 'next_month', labelKey: 'dateIndicators.nextMonth' }, ]; +const deferOptions = [ + { value: 'today', labelKey: 'dateIndicators.today' }, + { value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' }, + { value: 'next_week', labelKey: 'dateIndicators.nextWeek' }, + { value: 'next_month', labelKey: 'dateIndicators.nextMonth' }, +]; + const recurringOptions = [ { value: 'recurring', labelKey: 'search.recurringFilter.recurring' }, { value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' }, @@ -73,6 +80,7 @@ const SearchMenu: React.FC = ({ null ); const [selectedDue, setSelectedDue] = useState(null); + const [selectedDefer, setSelectedDefer] = useState(null); const [selectedTags, setSelectedTags] = useState([]); const [selectedRecurring, setSelectedRecurring] = useState( null @@ -112,6 +120,10 @@ const SearchMenu: React.FC = ({ setSelectedDue(selectedDue === due ? null : due); }; + const handleDeferToggle = (defer: string) => { + setSelectedDefer(selectedDefer === defer ? null : defer); + }; + const handleTagToggle = (tagName: string) => { setSelectedTags((prev) => prev.includes(tagName) @@ -148,6 +160,7 @@ const SearchMenu: React.FC = ({ filters: selectedFilters, priority: selectedPriority || null, due: selectedDue || null, + defer: selectedDefer || null, tags: selectedTags.length > 0 ? selectedTags : null, recurring: selectedRecurring || null, }), @@ -283,6 +296,27 @@ const SearchMenu: React.FC = ({ ); } + // Add defer until filter + if (selectedDefer) { + const deferOption = deferOptions.find( + (opt) => opt.value === selectedDefer + ); + const deferLabel = deferOption + ? t(deferOption.labelKey) + : selectedDefer; + parts.push( + {t('search.deferUntil') + ' '} + ); + parts.push( + + {deferLabel} + + ); + } + // Add tags filter if (selectedTags.length > 0) { parts.push( @@ -353,6 +387,7 @@ const SearchMenu: React.FC = ({ searchQuery.trim() || selectedPriority || selectedDue || + selectedDefer || selectedTags.length > 0 || selectedRecurring; @@ -477,6 +512,27 @@ const SearchMenu: React.FC = ({ + {/* Defer Until Filters */} +

+
+ {t('search.deferUntilFilter')} +
+
+ {deferOptions.map((option) => ( + + handleDeferToggle(option.value) + } + /> + ))} +
+
+ {/* Tags Filters */} {availableTags.length > 0 && (
@@ -625,6 +681,7 @@ const SearchMenu: React.FC = ({ selectedFilters={selectedFilters} selectedPriority={selectedPriority} selectedDue={selectedDue} + selectedDefer={selectedDefer} selectedTags={selectedTags} selectedRecurring={selectedRecurring} onClose={onClose} diff --git a/frontend/components/UniversalSearch/SearchResults.tsx b/frontend/components/UniversalSearch/SearchResults.tsx index 3df8288..9cdd94a 100644 --- a/frontend/components/UniversalSearch/SearchResults.tsx +++ b/frontend/components/UniversalSearch/SearchResults.tsx @@ -15,6 +15,7 @@ interface SearchResultsProps { selectedFilters: string[]; selectedPriority: string | null; selectedDue: string | null; + selectedDefer: string | null; selectedTags: string[]; selectedRecurring: string | null; onClose: () => void; @@ -36,6 +37,7 @@ const SearchResults: React.FC = ({ selectedFilters, selectedPriority, selectedDue, + selectedDefer, selectedTags, selectedRecurring, onClose, @@ -52,6 +54,7 @@ const SearchResults: React.FC = ({ selectedFilters.length === 0 && !selectedPriority && !selectedDue && + !selectedDefer && selectedTags.length === 0 && !selectedRecurring ) { @@ -66,6 +69,7 @@ const SearchResults: React.FC = ({ filters: selectedFilters, priority: selectedPriority || undefined, due: selectedDue || undefined, + defer: selectedDefer || undefined, tags: selectedTags.length > 0 ? selectedTags : undefined, recurring: selectedRecurring || undefined, }); @@ -85,6 +89,7 @@ const SearchResults: React.FC = ({ selectedFilters, selectedPriority, selectedDue, + selectedDefer, selectedTags, selectedRecurring, ]); diff --git a/frontend/entities/Project.ts b/frontend/entities/Project.ts index a1dd9f7..c2add54 100644 --- a/frontend/entities/Project.ts +++ b/frontend/entities/Project.ts @@ -7,6 +7,7 @@ export type ProjectState = | 'idea' | 'planned' | 'in_progress' + | 'active' | 'blocked' | 'completed'; diff --git a/frontend/utils/bannersService.ts b/frontend/utils/bannersService.ts new file mode 100644 index 0000000..270eda1 --- /dev/null +++ b/frontend/utils/bannersService.ts @@ -0,0 +1,72 @@ +export interface PresetBanner { + filename: string; + url: string; + creator: string; +} + +/** + * Extracts creator name from banner filename + * Format: "creator-name-rest-of-filename.jpg" + * Example: "jon-moore-5fIoyoKlz7A-unsplash.jpg" -> "Jon Moore" + */ +export function extractCreatorFromFilename(filename: string): string { + // Remove extension + const nameWithoutExt = filename.replace(/\.(jpg|jpeg|png|webp)$/i, ''); + + // Split by hyphen and take the first two parts (first and last name) + const parts = nameWithoutExt.split('-'); + + if (parts.length >= 2) { + // Capitalize first letter of each word + const firstName = parts[0].charAt(0).toUpperCase() + parts[0].slice(1); + const lastName = parts[1].charAt(0).toUpperCase() + parts[1].slice(1); + return `${firstName} ${lastName}`; + } + + // Fallback: just capitalize the first part + return parts[0].charAt(0).toUpperCase() + parts[0].slice(1); +} + +/** + * Gets the list of preset banner images + * In a real implementation, this would fetch from the server + * For now, we'll hardcode the known banners + */ +export function getPresetBanners(): PresetBanner[] { + const banners = [ + 'erwan-hesry-Q34YB7yjAxA-unsplash.jpg', + 'joanna-kosinska-spAkZnUleVw-unsplash.jpg', + 'jon-moore-5fIoyoKlz7A-unsplash.jpg', + 'marita-kavelashvili-ugnrXk1129g-unsplash.jpg', + 'mike-kotsch-9wTWFyInJ4Y-unsplash.jpg', + 'ohmky-uEusW9AW7QU-unsplash.jpg', + 'osman-rana-GXEZuWo5m4I-unsplash.jpg', + 'wil-stewart--m9PKhID7Nk-unsplash.jpg', + ]; + + return banners.map((filename) => ({ + filename, + url: `/banners/${filename}`, + creator: extractCreatorFromFilename(filename), + })); +} + +/** + * Checks if an image URL is a preset banner + */ +export function isPresetBanner(imageUrl: string): boolean { + if (!imageUrl) return false; + return imageUrl.startsWith('/banners/'); +} + +/** + * Gets creator name from a preset banner URL + */ +export function getCreatorFromBannerUrl(imageUrl: string): string | null { + if (!isPresetBanner(imageUrl)) return null; + + const filename = imageUrl.split('/').pop(); + if (!filename) return null; + + return extractCreatorFromFilename(filename); +} diff --git a/frontend/utils/searchService.ts b/frontend/utils/searchService.ts index e9e2af6..72fa1b6 100644 --- a/frontend/utils/searchService.ts +++ b/frontend/utils/searchService.ts @@ -5,6 +5,7 @@ interface SearchParams { filters?: string[]; priority?: string; due?: string; + defer?: string; tags?: string[]; recurring?: string; limit?: number; @@ -57,6 +58,10 @@ export const searchUniversal = async ( queryParams.append('due', params.due); } + if (params.defer) { + queryParams.append('defer', params.defer); + } + if (params.tags && params.tags.length > 0) { queryParams.append('tags', params.tags.join(',')); } diff --git a/frontend/utils/tasksService.ts b/frontend/utils/tasksService.ts index 3fbf374..2aa5a8b 100644 --- a/frontend/utils/tasksService.ts +++ b/frontend/utils/tasksService.ts @@ -21,6 +21,12 @@ export const fetchTasks = async ( tasks_due_today?: Task[]; suggested_tasks?: Task[]; tasks_completed_today?: Task[]; + pagination?: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; }> => { // For today view, include dashboard task lists const includeLists = query.includes('type=today'); @@ -60,6 +66,8 @@ export const fetchTasks = async ( tasks_due_today: tasksResult.tasks_due_today, suggested_tasks: tasksResult.suggested_tasks, tasks_completed_today: tasksResult.tasks_completed_today, + // Pagination metadata + pagination: tasksResult.pagination, }; }; diff --git a/public/banners/erwan-hesry-Q34YB7yjAxA-unsplash.jpg b/public/banners/erwan-hesry-Q34YB7yjAxA-unsplash.jpg new file mode 100644 index 0000000..eb168c6 Binary files /dev/null and b/public/banners/erwan-hesry-Q34YB7yjAxA-unsplash.jpg differ diff --git a/public/banners/joanna-kosinska-spAkZnUleVw-unsplash.jpg b/public/banners/joanna-kosinska-spAkZnUleVw-unsplash.jpg new file mode 100644 index 0000000..320284c Binary files /dev/null and b/public/banners/joanna-kosinska-spAkZnUleVw-unsplash.jpg differ diff --git a/public/banners/jon-moore-5fIoyoKlz7A-unsplash.jpg b/public/banners/jon-moore-5fIoyoKlz7A-unsplash.jpg new file mode 100644 index 0000000..2ffabd5 Binary files /dev/null and b/public/banners/jon-moore-5fIoyoKlz7A-unsplash.jpg differ diff --git a/public/banners/marita-kavelashvili-ugnrXk1129g-unsplash.jpg b/public/banners/marita-kavelashvili-ugnrXk1129g-unsplash.jpg new file mode 100644 index 0000000..501936e Binary files /dev/null and b/public/banners/marita-kavelashvili-ugnrXk1129g-unsplash.jpg differ diff --git a/public/banners/mike-kotsch-9wTWFyInJ4Y-unsplash.jpg b/public/banners/mike-kotsch-9wTWFyInJ4Y-unsplash.jpg new file mode 100644 index 0000000..9edc582 Binary files /dev/null and b/public/banners/mike-kotsch-9wTWFyInJ4Y-unsplash.jpg differ diff --git a/public/banners/ohmky-uEusW9AW7QU-unsplash.jpg b/public/banners/ohmky-uEusW9AW7QU-unsplash.jpg new file mode 100644 index 0000000..5833f40 Binary files /dev/null and b/public/banners/ohmky-uEusW9AW7QU-unsplash.jpg differ diff --git a/public/banners/osman-rana-GXEZuWo5m4I-unsplash.jpg b/public/banners/osman-rana-GXEZuWo5m4I-unsplash.jpg new file mode 100644 index 0000000..344522f Binary files /dev/null and b/public/banners/osman-rana-GXEZuWo5m4I-unsplash.jpg differ diff --git a/public/banners/wil-stewart--m9PKhID7Nk-unsplash.jpg b/public/banners/wil-stewart--m9PKhID7Nk-unsplash.jpg new file mode 100644 index 0000000..8df3ce7 Binary files /dev/null and b/public/banners/wil-stewart--m9PKhID7Nk-unsplash.jpg differ diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index 14cfe61..8f24ff5 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -1153,9 +1153,11 @@ "recurring": "قوالب متكررة", "nonRecurring": "غير متكرر", "instances": "حالات متكررة" - } + }, + "deferUntilFilter": "تأجيل حتى", + "deferUntil": "، تأجيل حتى" }, "subtasks": { "placeholder": "أضف مهمة فرعية..." } -} \ No newline at end of file +} diff --git a/public/locales/bg/translation.json b/public/locales/bg/translation.json index e05dce6..f3b4de7 100644 --- a/public/locales/bg/translation.json +++ b/public/locales/bg/translation.json @@ -1153,9 +1153,11 @@ "recurring": "повтарящи се шаблони", "nonRecurring": "неповтарящи се", "instances": "повтарящи се инстанции" - } + }, + "deferUntilFilter": "Отложи до", + "deferUntil": ", отложи до" }, "subtasks": { "placeholder": "Добавете подзадача..." } -} \ No newline at end of file +} diff --git a/public/locales/da/translation.json b/public/locales/da/translation.json index 4932fc1..deae877 100644 --- a/public/locales/da/translation.json +++ b/public/locales/da/translation.json @@ -1153,9 +1153,11 @@ "recurring": "gentagende skabeloner", "nonRecurring": "ikke-gentagende", "instances": "gentagende instanser" - } + }, + "deferUntilFilter": "Udskyd indtil", + "deferUntil": ", udskyd indtil" }, "subtasks": { "placeholder": "Tilføj en underopgave..." } -} \ No newline at end of file +} diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 971ead1..f34d88b 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -1162,9 +1162,11 @@ "recurring": "wiederkehrende Vorlagen", "nonRecurring": "nicht-wiederkehrend", "instances": "wiederkehrende Instanzen" - } + }, + "deferUntilFilter": "Bis zu", + "deferUntil": ", bis zu" }, "subtasks": { "placeholder": "Fügen Sie eine Unteraufgabe hinzu..." } -} \ No newline at end of file +} diff --git a/public/locales/el/translation.json b/public/locales/el/translation.json index b942795..e9cddbf 100644 --- a/public/locales/el/translation.json +++ b/public/locales/el/translation.json @@ -1157,9 +1157,11 @@ "recurring": "επαναλαμβανόμενα πρότυπα", "nonRecurring": "μη επαναλαμβανόμενο", "instances": "επαναλαμβανόμενες περιπτώσεις" - } + }, + "deferUntilFilter": "Αναβολή μέχρι", + "deferUntil": ", αναβολή μέχρι" }, "subtasks": { "placeholder": "Προσθέστε μια υποεργασία..." } -} \ No newline at end of file +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0d11d74..05d1112 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -779,11 +779,13 @@ "idea": "Idea", "planned": "Planned", "in_progress": "In Progress", + "active": "In Progress", "blocked": "Blocked", "completed": "Completed", "idea_desc": "Captured but not planned yet", "planned_desc": "Scoped and ready to start", "in_progress_desc": "Active work happening", + "active_desc": "Active work happening", "blocked_desc": "Temporarily paused or stuck", "completed_desc": "Finished and done" }, @@ -1134,6 +1136,8 @@ "extras": "Extras", "priorityFilter": "Priority", "dueFilter": "Due", + "deferUntilFilter": "Defer Until", + "deferUntil": ", defer until", "tagsFilter": "Tags", "recurringFilter": { "label": "Recurring", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index f686932..1c56f90 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -1154,9 +1154,11 @@ "recurring": "plantillas recurrentes", "nonRecurring": "no recurrente", "instances": "instancias recurrentes" - } + }, + "deferUntilFilter": "Aplazar hasta", + "deferUntil": ", aplazar hasta" }, "subtasks": { "placeholder": "Agregar una subtarea..." } -} \ No newline at end of file +} diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index 9afba25..d4e556b 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -1153,9 +1153,11 @@ "recurring": "toistuvat mallit", "nonRecurring": "ei-toistuva", "instances": "toistuvat instanssit" - } + }, + "deferUntilFilter": "Viivästytä kunnes", + "deferUntil": ", viivästytä kunnes" }, "subtasks": { "placeholder": "Lisää alitehtävä..." } -} \ No newline at end of file +} diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index c24aee4..7fef813 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -1153,9 +1153,11 @@ "recurring": "modèles récurrents", "nonRecurring": "non récurrent", "instances": "instances récurrentes" - } + }, + "deferUntilFilter": "Différer jusqu'à", + "deferUntil": ", différer jusqu'à" }, "subtasks": { "placeholder": "Ajouter une sous-tâche..." } -} \ No newline at end of file +} diff --git a/public/locales/id/translation.json b/public/locales/id/translation.json index d5acfa9..1aa5d3d 100644 --- a/public/locales/id/translation.json +++ b/public/locales/id/translation.json @@ -1153,9 +1153,11 @@ "recurring": "template berulang", "nonRecurring": "tidak berulang", "instances": "instansi berulang" - } + }, + "deferUntilFilter": "Tunda Hingga", + "deferUntil": ", tunda hingga" }, "subtasks": { "placeholder": "Tambahkan subtugas..." } -} \ No newline at end of file +} diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json index 444447b..242c8c7 100644 --- a/public/locales/it/translation.json +++ b/public/locales/it/translation.json @@ -1153,9 +1153,11 @@ "recurring": "modelli ricorrenti", "nonRecurring": "non ricorrente", "instances": "istanze ricorrenti" - } + }, + "deferUntilFilter": "Rimanda fino a", + "deferUntil": ", rimanda fino a" }, "subtasks": { "placeholder": "Aggiungi un sottocompito..." } -} \ No newline at end of file +} diff --git a/public/locales/jp/translation.json b/public/locales/jp/translation.json index 73fe843..62d6a94 100644 --- a/public/locales/jp/translation.json +++ b/public/locales/jp/translation.json @@ -1153,9 +1153,11 @@ "recurring": "定期的なテンプレート", "nonRecurring": "非定期的", "instances": "定期的なインスタンス" - } + }, + "deferUntilFilter": "フィルターまで遅延", + "deferUntil": "、遅延するまで" }, "subtasks": { "placeholder": "サブタスクを追加..." } -} \ No newline at end of file +} diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 2a3e82c..d7070f1 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -1153,9 +1153,11 @@ "recurring": "반복 템플릿", "nonRecurring": "비반복", "instances": "반복 인스턴스" - } + }, + "deferUntilFilter": "지연할 때까지", + "deferUntil": ", 지연할 때까지" }, "subtasks": { "placeholder": "하위 작업 추가..." } -} \ No newline at end of file +} diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json index 3cbbca1..2d6d4f7 100644 --- a/public/locales/nl/translation.json +++ b/public/locales/nl/translation.json @@ -1153,9 +1153,11 @@ "recurring": "herhalende sjablonen", "nonRecurring": "niet-herhalend", "instances": "herhalende instanties" - } + }, + "deferUntilFilter": "Uitstellen tot", + "deferUntil": ", uitstellen tot" }, "subtasks": { "placeholder": "Voeg een subtaak toe..." } -} \ No newline at end of file +} diff --git a/public/locales/no/translation.json b/public/locales/no/translation.json index fc1af82..9724e21 100644 --- a/public/locales/no/translation.json +++ b/public/locales/no/translation.json @@ -1153,9 +1153,11 @@ "recurring": "gjentakende maler", "nonRecurring": "ikke-gjentakende", "instances": "gjentakende instanser" - } + }, + "deferUntilFilter": "Utsett til", + "deferUntil": ", utsett til" }, "subtasks": { "placeholder": "Legg til en underoppgave..." } -} \ No newline at end of file +} diff --git a/public/locales/pl/translation.json b/public/locales/pl/translation.json index b03bf9a..9eaf83e 100644 --- a/public/locales/pl/translation.json +++ b/public/locales/pl/translation.json @@ -1153,9 +1153,11 @@ "recurring": "powtarzające się szablony", "nonRecurring": "niepowtarzające się", "instances": "powtarzające się instancje" - } + }, + "deferUntilFilter": "Odłóż do", + "deferUntil": ", odłóż do" }, "subtasks": { "placeholder": "Dodaj podzadanie..." } -} \ No newline at end of file +} diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index f0bfc64..9dcf5d0 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -1153,9 +1153,11 @@ "recurring": "modelos recorrentes", "nonRecurring": "não recorrente", "instances": "instâncias recorrentes" - } + }, + "deferUntilFilter": "Aguardar Até", + "deferUntil": ", aguardar até" }, "subtasks": { "placeholder": "Adicionar uma subtarefa..." } -} \ No newline at end of file +} diff --git a/public/locales/ro/translation.json b/public/locales/ro/translation.json index b33935e..96a6326 100644 --- a/public/locales/ro/translation.json +++ b/public/locales/ro/translation.json @@ -1153,9 +1153,11 @@ "recurring": "șabloane recurente", "nonRecurring": "non-recurent", "instances": "instanțe recurente" - } + }, + "deferUntilFilter": "Amână până la", + "deferUntil": ", amână până la" }, "subtasks": { "placeholder": "Adaugă o subtask..." } -} \ No newline at end of file +} diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 4fa5fc4..2317be7 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -1153,9 +1153,11 @@ "recurring": "повторяющиеся шаблоны", "nonRecurring": "неповторяющийся", "instances": "повторяющиеся экземпляры" - } + }, + "deferUntilFilter": "Отложить до", + "deferUntil": ", отложить до" }, "subtasks": { "placeholder": "Добавить подзадачу..." } -} \ No newline at end of file +} diff --git a/public/locales/sl/translation.json b/public/locales/sl/translation.json index ce3fdc8..39d20dd 100644 --- a/public/locales/sl/translation.json +++ b/public/locales/sl/translation.json @@ -1153,9 +1153,11 @@ "recurring": "ponavljajoče se predloge", "nonRecurring": "neponavljajoče se", "instances": "ponavljajoče se instance" - } + }, + "deferUntilFilter": "Odloži do", + "deferUntil": ", odloži do" }, "subtasks": { "placeholder": "Dodaj podnalogo..." } -} \ No newline at end of file +} diff --git a/public/locales/sv/translation.json b/public/locales/sv/translation.json index 92f0c6b..3348664 100644 --- a/public/locales/sv/translation.json +++ b/public/locales/sv/translation.json @@ -1153,9 +1153,11 @@ "recurring": "återkommande mallar", "nonRecurring": "icke-återkommande", "instances": "återkommande instanser" - } + }, + "deferUntilFilter": "Skjut upp tills", + "deferUntil": ", skjuta upp tills" }, "subtasks": { "placeholder": "Lägg till en deluppgift..." } -} \ No newline at end of file +} diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index a0a4aa3..9b18b76 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -1153,9 +1153,11 @@ "recurring": "tekrarlayan şablonlar", "nonRecurring": "tekrarlamayan", "instances": "tekrarlayan örnekler" - } + }, + "deferUntilFilter": "Ertele", + "deferUntil": ", ertele" }, "subtasks": { "placeholder": "Bir alt görev ekle..." } -} \ No newline at end of file +} diff --git a/public/locales/ua/translation.json b/public/locales/ua/translation.json index fc59f10..2006e1d 100644 --- a/public/locales/ua/translation.json +++ b/public/locales/ua/translation.json @@ -1153,9 +1153,11 @@ "recurring": "повторювані шаблони", "nonRecurring": "неповторювані", "instances": "повторювані екземпляри" - } + }, + "deferUntilFilter": "Відкласти до", + "deferUntil": ", відкласти до" }, "subtasks": { "placeholder": "Додати підзадачу..." } -} \ No newline at end of file +} diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index fa681f4..14fb5ee 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -1153,9 +1153,11 @@ "recurring": "mẫu lặp lại", "nonRecurring": "không lặp lại", "instances": "các phiên bản lặp lại" - } + }, + "deferUntilFilter": "Hoãn lại cho đến", + "deferUntil": ", hoãn lại cho đến" }, "subtasks": { "placeholder": "Thêm một công việc phụ..." } -} \ No newline at end of file +} diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 43995ff..574a2a6 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1153,9 +1153,11 @@ "recurring": "循环模板", "nonRecurring": "非循环", "instances": "循环实例" - } + }, + "deferUntilFilter": "延迟到", + "deferUntil": ",延迟到" }, "subtasks": { "placeholder": "添加子任务..." } -} \ No newline at end of file +}