feat: update task user stories and enhance task dialogs with timezone support
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskItem } from './TaskRow';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
function parseAssigneesLocal(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
@@ -47,8 +51,10 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState('todo');
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [dueTime, setDueTime] = useState('');
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
@@ -62,14 +68,14 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
setPriority(task.priority ?? 'medium');
|
||||
setStatus(task.status ?? 'todo');
|
||||
if (task.dueDate) {
|
||||
const d = new Date(task.dueDate);
|
||||
const d = new TZDate(task.dueDate, timezone);
|
||||
setDueDate(d);
|
||||
setDueTime(
|
||||
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
||||
);
|
||||
setDueHour(String(d.getHours()).padStart(2, '0'));
|
||||
setDueMinute(String(d.getMinutes()).padStart(2, '0'));
|
||||
} else {
|
||||
setDueDate(undefined);
|
||||
setDueTime('');
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
}
|
||||
setProjectId(task.projectId ?? '');
|
||||
setAssignees(parseAssigneesLocal(task.assignee));
|
||||
@@ -111,14 +117,16 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const d = new Date(dueDate);
|
||||
if (dueTime) {
|
||||
const parts = dueTime.split(':');
|
||||
const h = parseInt(parts[0] ?? '0', 10);
|
||||
const m = parseInt(parts[1] ?? '0', 10);
|
||||
d.setHours(h, m, 0, 0);
|
||||
}
|
||||
resolvedDueDate = d.getTime();
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
updateTask.mutate({
|
||||
@@ -194,29 +202,62 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'}
|
||||
{dueDate
|
||||
? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueTime && (
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueTime}
|
||||
Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -26,6 +27,9 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
|
||||
interface NewTaskDialogProps {
|
||||
@@ -40,8 +44,10 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [dueTime, setDueTime] = useState('');
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
|
||||
// Multi-assignee state
|
||||
@@ -96,7 +102,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
setPriority('medium');
|
||||
setStatus(defaultStatus ?? 'todo');
|
||||
setDueDate(undefined);
|
||||
setDueTime('');
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAssignees([]);
|
||||
setAssigneeInput('');
|
||||
@@ -171,17 +178,19 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
// Resolve dueDate + optional time
|
||||
// Resolve dueDate + optional time in the selected timezone
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const d = new Date(dueDate);
|
||||
if (dueTime) {
|
||||
const parts = dueTime.split(':');
|
||||
const h = parseInt(parts[0] ?? '0', 10);
|
||||
const m = parseInt(parts[1] ?? '0', 10);
|
||||
d.setHours(h, m, 0, 0);
|
||||
}
|
||||
resolvedDueDate = d.getTime();
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
// If creating a new project inline, do that first
|
||||
@@ -268,7 +277,7 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? format(dueDate, dueTime ? 'PPP' : 'PPP')
|
||||
? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -276,22 +285,54 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
{/* Time row */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueTime && (
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueTime}
|
||||
Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,11 @@ import { parseAssignees, type TaskItem } from './TaskRow';
|
||||
function formatDate(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
|
||||
const date = `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
|
||||
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${date} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function relativeTime(timestamp: number): string {
|
||||
|
||||
Reference in New Issue
Block a user