Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The refresh button now has a tooltip indicating when the query was last refreshed.
- You can now provide `show: none` in your query to hide all task metadata (project, due date, labels, description).
- Your tasks' durations will now be rendered with the due date.
- You can now set a task duration when creating a new task.

### 🔁 Changes

Expand Down
12 changes: 12 additions & 0 deletions plugin/src/i18n/langs/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ export const en: Translations = {
timeLabel: "Time",
saveButtonLabel: "Save",
cancelButtonLabel: "Cancel",
durationLabel: "Duration",
noDuration: "No duration",
duration: (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;

if (hours === 0) {
return `${mins}m`;
}

return `${hours}h ${mins}m`;
},
},
},
labelSelector: {
Expand Down
3 changes: 3 additions & 0 deletions plugin/src/i18n/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export type Translations = {
timeLabel: string;
saveButtonLabel: string;
cancelButtonLabel: string;
durationLabel: string;
noDuration: string;
duration: (minutes: number) => string;
};
};
labelSelector: {
Expand Down
205 changes: 138 additions & 67 deletions plugin/src/ui/createTaskModal/DueDateSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { DueDate as ApiDueDate } from "@/api/domain/dueDate";
import type { Duration as ApiDuration } from "@/api/domain/task";
import { DueDate as DataDueDate } from "@/data/dueDate";
import { t } from "@/i18n";
import { timezone } from "@/infra/time";
import { now, timezone } from "@/infra/time";
import { ObsidianIcon } from "@/ui/components/obsidian-icon";
import { Popover } from "@/ui/createTaskModal/Popover";
import {
type CalendarDate,
DateFormatter,
type Time,
Time,
endOfWeek,
isToday,
toCalendarDateTime,
toZoned,
today,
} from "@internationalized/date";
import type React from "react";
Expand All @@ -23,31 +28,28 @@ import {
Heading,
type Key,
Label,
ListBox,
ListBoxItem,
Menu,
MenuItem,
Section,
Select,
SelectValue,
TimeField,
} from "react-aria-components";
import { ObsidianIcon } from "../components/obsidian-icon";
import { Popover } from "./Popover";

const formatter = new DateFormatter("en-US", {
month: "short",
day: "numeric",
});

const timeFormatter = new DateFormatter("en-US", {
hour: "numeric",
minute: "2-digit",
});

const weekdayFormatter = new DateFormatter("en-US", {
weekday: "short",
});

export type DueDate = {
date: CalendarDate;
time: Time | undefined;
timeInfo:
| {
time: Time;
duration: ApiDuration | undefined;
}
| undefined;
};

type Props = {
Expand All @@ -61,9 +63,9 @@ export const DueDateSelector: React.FC<Props> = ({ selected, setSelected }) => {

const selectDate = (date: CalendarDate) => {
if (selected === undefined) {
setSelected({ date, time: undefined });
setSelected({ date, timeInfo: undefined });
} else {
setSelected({ date, time: selected.time });
setSelected({ date, timeInfo: selected.timeInfo });
}
};

Expand All @@ -76,22 +78,22 @@ export const DueDateSelector: React.FC<Props> = ({ selected, setSelected }) => {
if (suggestion.target === undefined) {
setSelected(undefined);
} else {
setSelected({ date: suggestion.target, time: selected?.time });
setSelected({ date: suggestion.target, timeInfo: selected?.timeInfo });
}
};

const setTime = (time: Time | undefined) => {
const setTimeInfo = (timeInfo: DueDate["timeInfo"]) => {
if (selected === undefined) {
if (time !== undefined) {
if (timeInfo !== undefined) {
setSelected({
date: today(timezone()),
time,
timeInfo,
});
}
} else {
setSelected({
date: selected.date,
time,
timeInfo,
});
}
};
Expand Down Expand Up @@ -142,23 +144,26 @@ export const DueDateSelector: React.FC<Props> = ({ selected, setSelected }) => {
<CalendarGrid>{(date) => <CalendarCell date={date} />}</CalendarGrid>
</Calendar>
<hr />
<DialogTrigger>
<div className="time-picker-container">
<Button className="time-picker-button">
<div className="time-picker-container">
<DialogTrigger>
<Button className="time-picker-button" aria-label="Set time">
<ObsidianIcon size="xs" id="clock" />
Time
</Button>
</div>
<Popover defaultPlacement="top">
<TimeDialog
selectedTime={selected?.time}
setTime={(time) => {
close();
setTime(time);
}}
/>
</Popover>
</DialogTrigger>
<Popover defaultPlacement="top">
<TimeDialog selectedTimeInfo={selected?.timeInfo} setTimeInfo={setTimeInfo} />
</Popover>
</DialogTrigger>
{selected?.timeInfo !== undefined && (
<Button
className="time-picker-clear-button"
onPress={() => setTimeInfo(undefined)}
aria-label="Clear time"
>
<ObsidianIcon size="xs" id="cross" />
</Button>
)}
</div>
</>
)}
</Dialog>
Expand All @@ -168,35 +173,25 @@ export const DueDateSelector: React.FC<Props> = ({ selected, setSelected }) => {
};

const getLabel = (selected: DueDate | undefined) => {
const i18n = t().createTaskModal.dateSelector;

if (selected === undefined) {
return i18n.emptyDate;
return t().createTaskModal.dateSelector.emptyDate;
}

const date = selected.date;
const dayPart = (() => {
if (isToday(date, timezone())) {
return i18n.today;
}

if (today(timezone()).add({ days: 1 }).compare(date) === 0) {
return i18n.tomorrow;
}

return formatter.format(date.toDate(timezone()));
})();

const time = selected.time;
const timePart = (() => {
if (time === undefined) {
return "";
}
const apiDueDate: ApiDueDate = {
date: selected.date.toString(),
datetime:
selected.timeInfo !== undefined
? toZoned(
toCalendarDateTime(selected.date, selected.timeInfo.time),
timezone(),
).toAbsoluteString()
: undefined,
isRecurring: false,
};

return timeFormatter.format(toCalendarDateTime(today(timezone()), time).toDate(timezone()));
})();
const dueDate = DataDueDate.parse(apiDueDate, selected?.timeInfo?.duration);

return [dayPart, timePart].join(" ").trimEnd();
return DataDueDate.format(dueDate);
};

type DateSuggestionProps = {
Expand Down Expand Up @@ -257,33 +252,109 @@ const getSuggestions = (): DateSuggestionProps[] => {
};

type TimeDialogProps = {
selectedTime: Time | undefined;
setTime: (time: Time | undefined) => void;
selectedTimeInfo: DueDate["timeInfo"] | undefined;
setTimeInfo: (timeInfo: DueDate["timeInfo"] | undefined) => void;
};

const TimeDialog: React.FC<TimeDialogProps> = ({ selectedTime, setTime }) => {
const [taskTime, setTaskTime] = useState(selectedTime);
// We want enough options to get to 23h 45m.
const MAX_DURATION_SEGMENTS = (24 * 60 - 15) / 15;

const TimeDialog: React.FC<TimeDialogProps> = ({ selectedTimeInfo, setTimeInfo }) => {
const i18n = t().createTaskModal.dateSelector.timeDialog;

const durationOptions = [
undefined,
...Array.from({ length: MAX_DURATION_SEGMENTS }, (_, i) => ({
amount: (i + 1) * 15,
unit: "minute" as const,
})),
].map((option) => ({
label: option === undefined ? i18n.noDuration : i18n.duration(option.amount),
value: option,
}));

const initialDurationIndex = durationOptions.findIndex(
(o) => o.value?.amount === selectedTimeInfo?.duration?.amount,
);

const [selectedDurationIndex, setSelectedDurationIndex] = useState<number>(
initialDurationIndex === -1 ? 0 : initialDurationIndex,
);
const [taskTimeInfo, setTaskTimeInfo] = useState(selectedTimeInfo);

const onDurationChange = (key: Key) => {
const idx = Number(key);
setSelectedDurationIndex(idx);

const option = durationOptions[idx];
if (taskTimeInfo?.time) {
setTaskTimeInfo({
time: taskTimeInfo.time,
duration: option.value,
});
} else {
setTaskTimeInfo({
time: new Time(now().hour, now().minute, 0),
duration: option.value,
});
}
};

return (
<Dialog className="task-option-dialog task-time-menu" aria-label="Time selector">
{({ close }) => (
<>
<TimeField className="task-time-picker" value={taskTime ?? null} onChange={setTaskTime}>
<TimeField
className="task-time-picker"
value={taskTimeInfo?.time ?? null}
onChange={(time) => {
setTaskTimeInfo({
time,
duration: taskTimeInfo?.duration,
});
}}
>
<Label className="task-time-picker-label">{i18n.timeLabel}</Label>
<DateInput className="task-time-picker-input">
{(segment) => (
<DateSegment className="task-time-picker-input-segment" segment={segment} />
)}
</DateInput>
</TimeField>
<Select
className="task-duration-select"
selectedKey={selectedDurationIndex}
onSelectionChange={onDurationChange}
>
<Label className="task-duration-picker-label">{i18n.durationLabel}</Label>
<Button className="task-duration-button">
<SelectValue />
</Button>
<Popover defaultPlacement="top" maxHeight={150}>
<ListBox
className="task-option-dialog task-duration-menu"
aria-label={i18n.durationLabel}
>
{durationOptions.map((option, index) => (
<ListBoxItem
key={String(index)}
id={index}
className="duration-option"
textValue={option.label}
>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</Select>
<div className="task-time-controls">
<Button onPress={close}>{i18n.cancelButtonLabel}</Button>
<Button
className="mod-cta"
onPress={() => {
close();
setTime(taskTime);
setTimeInfo(taskTimeInfo);
}}
>
{i18n.saveButtonLabel}
Expand Down
4 changes: 2 additions & 2 deletions plugin/src/ui/createTaskModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ const CreateTaskModalContent: React.FC<CreateTaskProps> = ({
};

if (dueDate !== undefined) {
if (dueDate.time !== undefined) {
if (dueDate.timeInfo !== undefined) {
params.dueDatetime = toZoned(
toCalendarDateTime(dueDate.date, dueDate.time),
toCalendarDateTime(dueDate.date, dueDate.timeInfo.time),
timezone(),
).toAbsoluteString();
} else {
Expand Down
Loading