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