feat(tasks): due dates with optional time; alarms at the right moment
Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
300fd6a7be
commit
344f5e4ab2
21 files changed
+391
-50
No files matched your search
@@ -562,7 +562,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
|
||||
<strong className="text-main">Fälligkeit mit Uhrzeit:</strong> Im Detail-Fenster neben dem Datum optional eine Uhrzeit setzen; „✕ Uhrzeit“ macht die Aufgabe wieder ganztägig. Angezeigt wird z.B. „Heute, 14:30“, Überfälliges in Rot.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Automatische Alarme:</strong> Zur Fälligkeit (ganztägige Aufgaben um 9:00, abzüglich deiner Vorlaufzeit) benachrichtigt MindSift dich per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook. Verschiebst du die Fälligkeit oder öffnest die Aufgabe wieder, kommt der Alarm erneut. Konfigurierbar in deinen Benutzereinstellungen.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
@@ -165,15 +166,15 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const dueParts = splitDue(task.dueDate);
|
||||
|
||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||
if (daysOffset === null) {
|
||||
onUpdateDueDate(task, '');
|
||||
return;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
onUpdateDueDate(task, dateStr);
|
||||
// Keep a chosen time when only the day changes
|
||||
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
|
||||
};
|
||||
|
||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||
@@ -469,21 +470,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Fälligkeitsdatum:</span>
|
||||
<span>Fällig:</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={task.dueDate || ''}
|
||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
||||
aria-label="Fälligkeitsdatum"
|
||||
value={dueParts.date}
|
||||
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
||||
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
aria-label="Uhrzeit (optional)"
|
||||
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
|
||||
value={dueParts.time}
|
||||
disabled={!dueParts.date}
|
||||
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
||||
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
|
||||
/>
|
||||
{task.dueDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateDueDate(task, '')}
|
||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
||||
title="Datum entfernen"
|
||||
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
|
||||
title="Fälligkeit entfernen"
|
||||
aria-label="Fälligkeit entfernen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -511,6 +523,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
>
|
||||
+1 Woche
|
||||
</button>
|
||||
{dueParts.time && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateDueDate(task, dueParts.date)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
|
||||
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
Uhrzeit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onUpdateRecurrence && (
|
||||
<div className="pt-1">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
@@ -416,10 +417,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
||||
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
||||
} else if (groupBy === 'due') {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
|
||||
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
|
||||
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
|
||||
const now = new Date();
|
||||
const today = localDay(now);
|
||||
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
|
||||
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
|
||||
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
|
||||
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
||||
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
||||
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
||||
@@ -1311,9 +1313,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* Due Date */}
|
||||
{task.dueDate && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||
>
|
||||
<Calendar className="w-2.5 h-2.5 text-accent" />
|
||||
<span>{task.dueDate}</span>
|
||||
<span>{formatDue(task.dueDate)}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
@@ -2335,9 +2340,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||
>
|
||||
<Calendar className="w-2.5 h-2.5" />
|
||||
<span>{task.dueDate}</span>
|
||||
<span>{formatDue(task.dueDate)}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { SharedList, Thought } from '../../lib/types';
|
||||
import { addDays, formatDue } from '../../lib/due';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface TriageDeckProps {
|
||||
@@ -223,10 +224,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
const [cards, setCards] = useState<Thought[]>(deck);
|
||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||
const [showDateModal, setShowDateModal] = useState(false);
|
||||
const [taskDueDate, setTaskDueDate] = useState(() => {
|
||||
const tomorrow = new Date(Date.now() + 86400000);
|
||||
return tomorrow.toISOString().slice(0, 10);
|
||||
});
|
||||
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
|
||||
const [showListSelector, setShowListSelector] = useState(false);
|
||||
|
||||
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
||||
@@ -666,7 +664,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
|
||||
{activeCard.dueDate && (
|
||||
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
|
||||
📅 {activeCard.dueDate}
|
||||
📅 {formatDue(activeCard.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDue, isOverdue, isDueToday, joinDue, splitDue } from '../due';
|
||||
|
||||
const now = new Date(2026, 9, 29, 14, 0); // Thu 29.10.2026, 14:00 local
|
||||
|
||||
describe('due dates', () => {
|
||||
it('splits and joins date and time', () => {
|
||||
expect(splitDue('2026-10-30T14:30')).toEqual({ date: '2026-10-30', time: '14:30' });
|
||||
expect(splitDue('2026-10-30')).toEqual({ date: '2026-10-30', time: '' });
|
||||
expect(joinDue('2026-10-30', '09:15')).toBe('2026-10-30T09:15');
|
||||
expect(joinDue('2026-10-30', '')).toBe('2026-10-30');
|
||||
expect(joinDue('', '09:15')).toBe('');
|
||||
});
|
||||
|
||||
it('formats relative to today', () => {
|
||||
expect(formatDue('2026-10-29T14:30', now)).toBe('Heute, 14:30');
|
||||
expect(formatDue('2026-10-30', now)).toBe('Morgen');
|
||||
expect(formatDue('2026-10-28', now)).toBe('Gestern');
|
||||
expect(formatDue('2026-11-06T08:00', now)).toBe('Fr., 06.11., 08:00');
|
||||
expect(formatDue('2027-01-02', now)).toBe('02.01.2027');
|
||||
});
|
||||
|
||||
it('knows overdue to the minute', () => {
|
||||
expect(isOverdue('2026-10-29T13:59', now)).toBe(true);
|
||||
expect(isOverdue('2026-10-29T14:30', now)).toBe(false);
|
||||
expect(isOverdue('2026-10-29', now)).toBe(false);
|
||||
expect(isOverdue('2026-10-28', now)).toBe(true);
|
||||
expect(isDueToday('2026-10-29T08:00', now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Due dates come as "YYYY-MM-DD" (all day) or "YYYY-MM-DDTHH:MM" (with time), in the server's time zone.
|
||||
// Both sort correctly as plain strings.
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** Local calendar day as YYYY-MM-DD (toISOString would give the UTC day). */
|
||||
export const localDay = (d: Date = new Date()): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
export const addDays = (days: number, from: Date = new Date()): string => {
|
||||
const d = new Date(from);
|
||||
d.setDate(d.getDate() + days);
|
||||
return localDay(d);
|
||||
};
|
||||
|
||||
export const splitDue = (due?: string): { date: string; time: string } => {
|
||||
if (!due) return { date: '', time: '' };
|
||||
const [date, time = ''] = due.split('T');
|
||||
return { date, time: time.slice(0, 5) };
|
||||
};
|
||||
|
||||
export const joinDue = (date: string, time: string): string => (date ? (time ? `${date}T${time}` : date) : '');
|
||||
|
||||
/** A task with a time is overdue once that minute has passed, an all-day task from the next day on. */
|
||||
export const isOverdue = (due: string | undefined, now: Date = new Date()): boolean => {
|
||||
if (!due) return false;
|
||||
const { date, time } = splitDue(due);
|
||||
const today = localDay(now);
|
||||
if (date !== today) return date < today;
|
||||
return !!time && time < `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
};
|
||||
|
||||
export const isDueToday = (due: string | undefined, now: Date = new Date()): boolean =>
|
||||
!!due && splitDue(due).date === localDay(now);
|
||||
|
||||
const WEEKDAYS = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
|
||||
|
||||
/** Short label: "Heute, 14:30", "Morgen", "Gestern", "Fr., 30.10." or "30.10.2027" in another year. */
|
||||
export const formatDue = (due: string | undefined, now: Date = new Date()): string => {
|
||||
if (!due) return '';
|
||||
const { date, time } = splitDue(due);
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
if (!y || !m || !d) return due;
|
||||
let label: string;
|
||||
if (date === localDay(now)) label = 'Heute';
|
||||
else if (date === addDays(1, now)) label = 'Morgen';
|
||||
else if (date === addDays(-1, now)) label = 'Gestern';
|
||||
else if (y === now.getFullYear()) label = `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${pad(d)}.${pad(m)}.`;
|
||||
else label = `${pad(d)}.${pad(m)}.${y}`;
|
||||
return time ? `${label}, ${time}` : label;
|
||||
};
|
||||
Reference in new issue
Block a user