Fix today pagination (#596)

* fixup! Feat notifications (#594)

* Add pagination to today

* Add defer to search

* fixup! Add defer to search

* Add preuploaded banners

* fixup! Add preuploaded banners

* Fix project banner

* fixup! Fix project banner

* fixup! fixup! Fix project banner
This commit is contained in:
Chris 2025-11-26 23:00:50 +02:00 committed by GitHub
parent 18c7785b13
commit 08c23d2f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1058 additions and 295 deletions

View file

@ -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) {

View file

@ -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 = [];

View file

@ -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<void>;
currentImageUrl?: string;
}
const BannerEditModal: React.FC<BannerEditModalProps> = ({
isOpen,
onClose,
onSave,
currentImageUrl = '',
}) => {
const { t } = useTranslation();
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>(currentImageUrl);
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [presetBanners] = useState<PresetBanner[]>(getPresetBanners());
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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<string | null> => {
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(
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-5xl transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[700px] sm:max-h-[90vh]">
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 sm:rounded-lg">
<div className="flex-1 relative">
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="p-6">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">
{t(
'project.editBanner',
'Edit Project Banner'
)}
</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-red-600 dark:text-red-400 text-sm">
{error}
</p>
</div>
)}
{imagePreview && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'project.currentBanner',
'Current Banner'
)}
</h3>
<div className="relative inline-block w-full">
<img
src={imagePreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-md border border-gray-300 dark:border-gray-600"
/>
<button
type="button"
onClick={handleRemoveImage}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 shadow-lg"
>
×
</button>
</div>
</div>
)}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'project.choosePreset',
'Choose a preset banner:'
)}
</h3>
<div className="grid grid-cols-2 gap-3">
{presetBanners.map((banner) => (
<button
key={banner.filename}
type="button"
onClick={() =>
handlePresetBannerSelect(
banner
)
}
className={`relative rounded-md overflow-hidden border-2 transition-all ${
imagePreview ===
banner.url
? 'border-blue-500 ring-2 ring-blue-300 dark:ring-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:border-blue-400'
}`}
>
<img
src={banner.url}
alt={`Banner by ${banner.creator}`}
className="w-full h-24 object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-60 text-white text-xs px-2 py-1 text-center">
{banner.creator}
</div>
</button>
))}
</div>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'project.orUploadOwn',
'Or upload your own:'
)}
</h3>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<button
type="button"
onClick={() =>
fileInputRef.current?.click()
}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{t(
'project.browseImage',
'Browse Image'
)}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{t(
'project.uploadImageHint',
'Upload an image for your project (max 10MB)'
)}
</p>
</div>
</div>
</div>
</div>
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-end space-x-3 sm:rounded-b-lg">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isUploading || isSaving}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isUploading || isSaving
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
{isUploading
? t('common.uploading', 'Uploading...')
: isSaving
? t('common.saving', 'Saving...')
: t('common.save', 'Save')}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default BannerEditModal;

View file

@ -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<HTMLButtonElement>;
onEditBannerClick?: () => void;
}
const ProjectBanner: React.FC<ProjectBannerProps> = ({
@ -28,8 +34,13 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
getStateIcon,
onDeleteClick,
editButtonRef,
onEditBannerClick,
}) => {
const navigate = useNavigate();
const creatorName =
project.image_url && isPresetBanner(project.image_url)
? getCreatorFromBannerUrl(project.image_url)
: null;
return (
<div className="w-full">
@ -38,10 +49,16 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
<img
src={project.image_url}
alt={project.name}
className="w-full h-64 object-cover"
className="w-full h-[282px] object-cover"
/>
) : (
<div className="w-full h-64 bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
<div className="w-full h-[282px] bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
{creatorName && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded backdrop-blur-sm">
Photo by {creatorName}
</div>
)}
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
@ -148,6 +165,20 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
</div>
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{onEditBannerClick && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEditBannerClick();
}}
className="p-2 bg-black bg-opacity-50 text-purple-400 hover:text-purple-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
title={t('project.editBanner', 'Edit Banner')}
>
<CameraIcon className="h-5 w-5" />
</button>
)}
<button
ref={editButtonRef}
type="button"

View file

@ -42,6 +42,7 @@ import { usePersistedModal } from '../../hooks/usePersistedModal';
import { getApiPath } from '../../config/paths';
import ProjectInsightsPanel from './ProjectInsightsPanel';
import ProjectBanner from './ProjectBanner';
import BannerEditModal from './BannerEditModal';
import ProjectTasksSection from './ProjectTasksSection';
import ProjectNotesSection from './ProjectNotesSection';
import { useProjectMetrics } from './useProjectMetrics';
@ -65,6 +66,7 @@ const ProjectDetails: React.FC = () => {
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isBannerEditModalOpen, setIsBannerEditModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks');
const [taskStatusFilter, setTaskStatusFilter] = useState<
'all' | 'active' | 'completed'
@ -454,6 +456,29 @@ const ProjectDetails: React.FC = () => {
closeModal();
};
const handleEditBannerClick = () => {
setIsBannerEditModalOpen(true);
};
const handleSaveBanner = async (imageUrl: string) => {
if (!project || !project.uid) return;
const updatedProject = await updateProject(project.uid, {
...project,
image_url: imageUrl,
});
setProject((prev) => ({
...updatedProject,
area: updatedProject.area || prev?.area,
Area: (updatedProject as any).Area || (prev as any)?.Area,
}));
showSuccessToast(
t('success.bannerUpdated', 'Banner updated successfully!')
);
};
const handleCreateNextAction = async (
projectId: number,
actionDescription: string
@ -740,6 +765,7 @@ const ProjectDetails: React.FC = () => {
<ClipboardDocumentListIcon className="h-3 w-3 text-blue-500 flex-shrink-0 mt-0.5" />
);
case 'in_progress':
case 'active':
return (
<PlayIcon className="h-3 w-3 text-green-500 flex-shrink-0 mt-0.5" />
);
@ -868,6 +894,7 @@ const ProjectDetails: React.FC = () => {
setIsConfirmDialogOpen(true);
}}
editButtonRef={editButtonRef}
onEditBannerClick={handleEditBannerClick}
/>
<div className="w-full px-4 sm:px-6 lg:px-10">
@ -1132,6 +1159,13 @@ const ProjectDetails: React.FC = () => {
areas={areas}
/>
<BannerEditModal
isOpen={isBannerEditModalOpen}
onClose={() => setIsBannerEditModalOpen(false)}
onSave={handleSaveBanner}
currentImageUrl={project.image_url}
/>
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {

View file

@ -67,6 +67,7 @@ const getStateLabel = (state: ProjectState | undefined, t: any): string => {
case 'planned':
return t('projects.states.planned', 'Planned');
case 'in_progress':
case 'active':
return t('projects.states.in_progress', 'In Progress');
case 'blocked':
return t('projects.states.blocked', 'Blocked');

View file

@ -17,12 +17,10 @@ import {
TagIcon,
Squares2X2Icon,
TrashIcon,
CameraIcon,
CalendarIcon,
ExclamationTriangleIcon,
PlayIcon,
} from '@heroicons/react/24/outline';
import { getApiPath } from '../../config/paths';
interface ProjectModalProps {
isOpen: boolean;
@ -51,18 +49,12 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
tags: [],
priority: null,
due_date_at: null,
image_url: '',
}
);
const [tags, setTags] = useState<string[]>(
project?.tags?.map((tag) => tag.name) || []
);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>(
project?.image_url || ''
);
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { tagsStore } = useStore();
@ -71,7 +63,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const { addNewTags } = tagsStore;
const modalRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@ -83,7 +74,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
state: false,
tags: false,
area: false,
image: false,
priority: false,
dueDate: false,
});
@ -131,10 +121,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
...project,
tags: project.tags || [],
due_date_at: dueDateValue || null,
image_url: project.image_url || '',
});
setTags(project.tags?.map((tag) => tag.name) || []);
setImagePreview(project.image_url || '');
} else {
setFormData({
name: '',
@ -144,12 +132,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
tags: [],
priority: null,
due_date_at: null,
image_url: '',
});
setTags([]);
setImagePreview('');
}
setImageFile(null);
setError(null);
}, [project]);
@ -274,92 +259,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}));
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Simple client-side guard (10MB max)
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);
// Create preview
const reader = new FileReader();
reader.onload = (ev) => {
setImagePreview(ev.target?.result as string);
};
reader.readAsDataURL(file);
};
const handleImageUpload = async (): Promise<string | null> => {
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('');
setFormData((prev) => ({
...prev,
image_url: '',
}));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSubmit = async () => {
// Validate required fields
if (!formData.name.trim()) {
@ -380,22 +279,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
addNewTags(newTagNames);
}
let imageUrl = formData.image_url;
// Upload image if a new one was selected
if (imageFile) {
const uploadedImageUrl = await handleImageUpload();
if (uploadedImageUrl) {
imageUrl = uploadedImageUrl;
} else {
setIsSaving(false);
return;
}
}
const projectData = {
...formData,
image_url: imageUrl,
tags: tags.map((name) => ({ name })),
};
@ -446,9 +331,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
formData.state !== 'idea' ||
tags.length > 0 ||
formData.priority !== null ||
formData.due_date_at !== null ||
imageFile !== null ||
imagePreview !== ''
formData.due_date_at !== null
);
}
@ -459,8 +342,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
formData.area_id !== project.area_id ||
formData.state !== project.state ||
formData.priority !== project.priority ||
formData.due_date_at !== project.due_date_at ||
imageFile !== null;
formData.due_date_at !== project.due_date_at;
// Compare tags
const originalTags = project.tags?.map((tag) => tag.name) || [];
@ -707,81 +589,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
)}
{expandedSections.image && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'project.projectImage',
'Project Image'
)}
</h3>
{imagePreview ? (
<div className="mb-3">
<div className="relative inline-block">
<img
src={
imagePreview
}
alt="Project preview"
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
/>
<button
type="button"
onClick={
handleRemoveImage
}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div>
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={
handleImageSelect
}
className="hidden"
/>
<button
type="button"
onClick={() =>
fileInputRef.current?.click()
}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{t(
'project.browseImage',
'Browse Image'
)}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
'project.uploadImageHint',
'Upload an image for your project (max 10MB)'
)}
</p>
</div>
)}
{expandedSections.priority && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
@ -901,28 +708,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
)}
</button>
{/* Project Image Toggle */}
<button
type="button"
onClick={() =>
toggleSection('image')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.image
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'project.projectImage',
'Project Image'
)}
>
<CameraIcon className="h-5 w-5" />
{formData.image_url && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Priority Toggle */}
<button
type="button"
@ -997,17 +782,10 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<button
type="button"
onClick={handleSubmit}
disabled={isUploading}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isUploading
? 'opacity-50 cursor-not-allowed'
: ''
}`}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm"
data-testid="project-save-button"
>
{isUploading
? 'Uploading...'
: project
{project
? t(
'modals.updateProject',
'Update Project'

View file

@ -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 && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() => handleLoadMore(false)}
disabled={isLoading}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('common.loading', 'Loading...')}
</>
) : (
<>
<QueueListIcon className="h-4 w-4 mr-2" />
{t('common.loadMore', 'Load More')}
</>
)}
</button>
<button
onClick={() => handleLoadMore(true)}
disabled={isLoading}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('common.showAll', 'Show All')}
</button>
</div>
)}
{/* Pagination info for Today Plan tasks */}
{(metrics.today_plan_tasks || []).length > 0 && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} items',
{
current: (metrics.today_plan_tasks || [])
.length,
total: pagination.total,
}
)}
</div>
)}
{/* Suggested Tasks - Separate setting */}
{!isSettingsLoaded ? (
// Invisible placeholder for suggestions
@ -1145,7 +1328,10 @@ const TasksToday: React.FC = () => {
{t('tasks.dueToday')}
</h3>
<TaskList
tasks={metrics.tasks_due_today}
tasks={metrics.tasks_due_today.slice(
0,
dueTodayDisplayLimit
)}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={localProjects}
@ -1154,6 +1340,49 @@ const TasksToday: React.FC = () => {
handleTaskCompletionToggle
}
/>
{/* Load More Buttons for Due Today Tasks */}
{dueTodayDisplayLimit <
metrics.tasks_due_today.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
setDueTodayDisplayLimit(
(prev) => prev + 20
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<QueueListIcon className="h-4 w-4 mr-2" />
{t('common.loadMore', 'Load More')}
</button>
<button
onClick={() =>
setDueTodayDisplayLimit(
metrics.tasks_due_today.length
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
{t('common.showAll', 'Show All')}
</button>
</div>
)}
{/* Pagination info for Due Today tasks */}
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} items',
{
current: Math.min(
dueTodayDisplayLimit,
metrics.tasks_due_today.length
),
total: metrics.tasks_due_today.length,
}
)}
</div>
</div>
)}
@ -1184,14 +1413,71 @@ const TasksToday: React.FC = () => {
</div>
{!isCompletedCollapsed &&
(completedToday.length > 0 ? (
<>
<TaskList
tasks={completedToday}
tasks={completedToday.slice(
0,
completedTodayDisplayLimit
)}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
onToggleToday={
handleToggleToday
}
showCompletedTasks={true}
/>
{/* Load More Buttons for Completed Today Tasks */}
{completedTodayDisplayLimit <
completedToday.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
setCompletedTodayDisplayLimit(
(prev) =>
prev + 20
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<QueueListIcon className="h-4 w-4 mr-2" />
{t(
'common.loadMore',
'Load More'
)}
</button>
<button
onClick={() =>
setCompletedTodayDisplayLimit(
completedToday.length
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
{t(
'common.showAll',
'Show All'
)}
</button>
</div>
)}
{/* Pagination info for Completed Today tasks */}
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} items',
{
current: Math.min(
completedTodayDisplayLimit,
completedToday.length
),
total: completedToday.length,
}
)}
</div>
</>
) : (
<p className="text-gray-500 dark:text-gray-400 text-center mt-4">
{t(

View file

@ -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<SearchMenuProps> = ({
null
);
const [selectedDue, setSelectedDue] = useState<string | null>(null);
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedRecurring, setSelectedRecurring] = useState<string | null>(
null
@ -112,6 +120,10 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
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<SearchMenuProps> = ({
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<SearchMenuProps> = ({
);
}
// Add defer until filter
if (selectedDefer) {
const deferOption = deferOptions.find(
(opt) => opt.value === selectedDefer
);
const deferLabel = deferOption
? t(deferOption.labelKey)
: selectedDefer;
parts.push(
<span key="defer-label">{t('search.deferUntil') + ' '}</span>
);
parts.push(
<span
key="defer"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{deferLabel}
</span>
);
}
// Add tags filter
if (selectedTags.length > 0) {
parts.push(
@ -353,6 +387,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
searchQuery.trim() ||
selectedPriority ||
selectedDue ||
selectedDefer ||
selectedTags.length > 0 ||
selectedRecurring;
@ -477,6 +512,27 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
</div>
</div>
{/* Defer Until Filters */}
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.deferUntilFilter')}
</div>
<div className="flex flex-wrap gap-2">
{deferOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
isSelected={
selectedDefer === option.value
}
onToggle={() =>
handleDeferToggle(option.value)
}
/>
))}
</div>
</div>
{/* Tags Filters */}
{availableTags.length > 0 && (
<div>
@ -625,6 +681,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
selectedFilters={selectedFilters}
selectedPriority={selectedPriority}
selectedDue={selectedDue}
selectedDefer={selectedDefer}
selectedTags={selectedTags}
selectedRecurring={selectedRecurring}
onClose={onClose}

View file

@ -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<SearchResultsProps> = ({
selectedFilters,
selectedPriority,
selectedDue,
selectedDefer,
selectedTags,
selectedRecurring,
onClose,
@ -52,6 +54,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
selectedFilters.length === 0 &&
!selectedPriority &&
!selectedDue &&
!selectedDefer &&
selectedTags.length === 0 &&
!selectedRecurring
) {
@ -66,6 +69,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
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<SearchResultsProps> = ({
selectedFilters,
selectedPriority,
selectedDue,
selectedDefer,
selectedTags,
selectedRecurring,
]);

View file

@ -7,6 +7,7 @@ export type ProjectState =
| 'idea'
| 'planned'
| 'in_progress'
| 'active'
| 'blocked'
| 'completed';

View file

@ -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);
}

View file

@ -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(','));
}

View file

@ -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,
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View file

@ -1153,7 +1153,9 @@
"recurring": "قوالب متكررة",
"nonRecurring": "غير متكرر",
"instances": "حالات متكررة"
}
},
"deferUntilFilter": "تأجيل حتى",
"deferUntil": "، تأجيل حتى"
},
"subtasks": {
"placeholder": "أضف مهمة فرعية..."

View file

@ -1153,7 +1153,9 @@
"recurring": "повтарящи се шаблони",
"nonRecurring": "неповтарящи се",
"instances": "повтарящи се инстанции"
}
},
"deferUntilFilter": "Отложи до",
"deferUntil": ", отложи до"
},
"subtasks": {
"placeholder": "Добавете подзадача..."

View file

@ -1153,7 +1153,9 @@
"recurring": "gentagende skabeloner",
"nonRecurring": "ikke-gentagende",
"instances": "gentagende instanser"
}
},
"deferUntilFilter": "Udskyd indtil",
"deferUntil": ", udskyd indtil"
},
"subtasks": {
"placeholder": "Tilføj en underopgave..."

View file

@ -1162,7 +1162,9 @@
"recurring": "wiederkehrende Vorlagen",
"nonRecurring": "nicht-wiederkehrend",
"instances": "wiederkehrende Instanzen"
}
},
"deferUntilFilter": "Bis zu",
"deferUntil": ", bis zu"
},
"subtasks": {
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."

View file

@ -1157,7 +1157,9 @@
"recurring": "επαναλαμβανόμενα πρότυπα",
"nonRecurring": "μη επαναλαμβανόμενο",
"instances": "επαναλαμβανόμενες περιπτώσεις"
}
},
"deferUntilFilter": "Αναβολή μέχρι",
"deferUntil": ", αναβολή μέχρι"
},
"subtasks": {
"placeholder": "Προσθέστε μια υποεργασία..."

View file

@ -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",

View file

@ -1154,7 +1154,9 @@
"recurring": "plantillas recurrentes",
"nonRecurring": "no recurrente",
"instances": "instancias recurrentes"
}
},
"deferUntilFilter": "Aplazar hasta",
"deferUntil": ", aplazar hasta"
},
"subtasks": {
"placeholder": "Agregar una subtarea..."

View file

@ -1153,7 +1153,9 @@
"recurring": "toistuvat mallit",
"nonRecurring": "ei-toistuva",
"instances": "toistuvat instanssit"
}
},
"deferUntilFilter": "Viivästytä kunnes",
"deferUntil": ", viivästytä kunnes"
},
"subtasks": {
"placeholder": "Lisää alitehtävä..."

View file

@ -1153,7 +1153,9 @@
"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..."

View file

@ -1153,7 +1153,9 @@
"recurring": "template berulang",
"nonRecurring": "tidak berulang",
"instances": "instansi berulang"
}
},
"deferUntilFilter": "Tunda Hingga",
"deferUntil": ", tunda hingga"
},
"subtasks": {
"placeholder": "Tambahkan subtugas..."

View file

@ -1153,7 +1153,9 @@
"recurring": "modelli ricorrenti",
"nonRecurring": "non ricorrente",
"instances": "istanze ricorrenti"
}
},
"deferUntilFilter": "Rimanda fino a",
"deferUntil": ", rimanda fino a"
},
"subtasks": {
"placeholder": "Aggiungi un sottocompito..."

View file

@ -1153,7 +1153,9 @@
"recurring": "定期的なテンプレート",
"nonRecurring": "非定期的",
"instances": "定期的なインスタンス"
}
},
"deferUntilFilter": "フィルターまで遅延",
"deferUntil": "、遅延するまで"
},
"subtasks": {
"placeholder": "サブタスクを追加..."

View file

@ -1153,7 +1153,9 @@
"recurring": "반복 템플릿",
"nonRecurring": "비반복",
"instances": "반복 인스턴스"
}
},
"deferUntilFilter": "지연할 때까지",
"deferUntil": ", 지연할 때까지"
},
"subtasks": {
"placeholder": "하위 작업 추가..."

View file

@ -1153,7 +1153,9 @@
"recurring": "herhalende sjablonen",
"nonRecurring": "niet-herhalend",
"instances": "herhalende instanties"
}
},
"deferUntilFilter": "Uitstellen tot",
"deferUntil": ", uitstellen tot"
},
"subtasks": {
"placeholder": "Voeg een subtaak toe..."

View file

@ -1153,7 +1153,9 @@
"recurring": "gjentakende maler",
"nonRecurring": "ikke-gjentakende",
"instances": "gjentakende instanser"
}
},
"deferUntilFilter": "Utsett til",
"deferUntil": ", utsett til"
},
"subtasks": {
"placeholder": "Legg til en underoppgave..."

View file

@ -1153,7 +1153,9 @@
"recurring": "powtarzające się szablony",
"nonRecurring": "niepowtarzające się",
"instances": "powtarzające się instancje"
}
},
"deferUntilFilter": "Odłóż do",
"deferUntil": ", odłóż do"
},
"subtasks": {
"placeholder": "Dodaj podzadanie..."

View file

@ -1153,7 +1153,9 @@
"recurring": "modelos recorrentes",
"nonRecurring": "não recorrente",
"instances": "instâncias recorrentes"
}
},
"deferUntilFilter": "Aguardar Até",
"deferUntil": ", aguardar até"
},
"subtasks": {
"placeholder": "Adicionar uma subtarefa..."

View file

@ -1153,7 +1153,9 @@
"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..."

View file

@ -1153,7 +1153,9 @@
"recurring": "повторяющиеся шаблоны",
"nonRecurring": "неповторяющийся",
"instances": "повторяющиеся экземпляры"
}
},
"deferUntilFilter": "Отложить до",
"deferUntil": ", отложить до"
},
"subtasks": {
"placeholder": "Добавить подзадачу..."

View file

@ -1153,7 +1153,9 @@
"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..."

View file

@ -1153,7 +1153,9 @@
"recurring": "återkommande mallar",
"nonRecurring": "icke-återkommande",
"instances": "återkommande instanser"
}
},
"deferUntilFilter": "Skjut upp tills",
"deferUntil": ", skjuta upp tills"
},
"subtasks": {
"placeholder": "Lägg till en deluppgift..."

View file

@ -1153,7 +1153,9 @@
"recurring": "tekrarlayan şablonlar",
"nonRecurring": "tekrarlamayan",
"instances": "tekrarlayan örnekler"
}
},
"deferUntilFilter": "Ertele",
"deferUntil": ", ertele"
},
"subtasks": {
"placeholder": "Bir alt görev ekle..."

View file

@ -1153,7 +1153,9 @@
"recurring": "повторювані шаблони",
"nonRecurring": "неповторювані",
"instances": "повторювані екземпляри"
}
},
"deferUntilFilter": "Відкласти до",
"deferUntil": ", відкласти до"
},
"subtasks": {
"placeholder": "Додати підзадачу..."

View file

@ -1153,7 +1153,9 @@
"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ụ..."

View file

@ -1153,7 +1153,9 @@
"recurring": "循环模板",
"nonRecurring": "非循环",
"instances": "循环实例"
}
},
"deferUntilFilter": "延迟到",
"deferUntil": ",延迟到"
},
"subtasks": {
"placeholder": "添加子任务..."