refactor: remove canvas, automations, and custom statuses; simplify notification and status model
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.20.0",
|
||||
@@ -64,6 +65,7 @@
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"recharts": "^3.10.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, useApiMutation } from "@/lib/api";
|
||||
import type {
|
||||
AutomationAction,
|
||||
AutomationActionType,
|
||||
AutomationCondition,
|
||||
AutomationConditionField,
|
||||
AutomationRule,
|
||||
AutomationTriggerType,
|
||||
Project,
|
||||
} from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// ── Shared metadata (also used by the Automations tab for summaries) ───────────
|
||||
|
||||
export const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string }[] = [
|
||||
{ value: "task_status_changed", label: "Task status changed" },
|
||||
{ value: "task_created", label: "Task created" },
|
||||
{ value: "due_date_approaching", label: "Due date approaching" },
|
||||
];
|
||||
|
||||
export const ACTION_OPTIONS: { value: AutomationActionType; label: string }[] = [
|
||||
{ value: "set_status", label: "Set status" },
|
||||
{ value: "set_priority", label: "Set priority" },
|
||||
{ value: "add_label", label: "Add label" },
|
||||
{ value: "create_notification", label: "Send notification" },
|
||||
];
|
||||
|
||||
export const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
];
|
||||
|
||||
const CONDITION_FIELD_OPTIONS: { value: AutomationConditionField; label: string }[] = [
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
{ value: "label", label: "Label" },
|
||||
];
|
||||
|
||||
const STATUS_OPS: { value: string; label: string }[] = [
|
||||
{ value: "to", label: "changes to" },
|
||||
{ value: "from", label: "changes from" },
|
||||
{ value: "eq", label: "is" },
|
||||
];
|
||||
|
||||
const PRIORITY_OPS: { value: string; label: string }[] = [
|
||||
{ value: "eq", label: "is" },
|
||||
{ value: "neq", label: "is not" },
|
||||
];
|
||||
|
||||
const LABEL_OPS: { value: string; label: string }[] = [
|
||||
{ value: "has", label: "has" },
|
||||
{ value: "not_has", label: "does not have" },
|
||||
];
|
||||
|
||||
function opsForField(field: AutomationConditionField): { value: string; label: string }[] {
|
||||
switch (field) {
|
||||
case "status":
|
||||
return STATUS_OPS;
|
||||
case "priority":
|
||||
return PRIORITY_OPS;
|
||||
case "label":
|
||||
return LABEL_OPS;
|
||||
default:
|
||||
return STATUS_OPS;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(project: Project, key: string): string {
|
||||
return project.statuses?.find((s) => s.key === key)?.label ?? key;
|
||||
}
|
||||
|
||||
function summarizeActions(project: Project, actions: AutomationAction[]): string[] {
|
||||
return actions.map((action) => {
|
||||
const params = action.params ?? {};
|
||||
switch (action.type) {
|
||||
case "set_status":
|
||||
return `Set status to ${statusLabel(project, String(params.statusKey ?? ""))}`;
|
||||
case "set_priority":
|
||||
return `Set priority to ${String(params.priority ?? "")}`;
|
||||
case "add_label":
|
||||
return `Add label "${String(params.label ?? "")}"`;
|
||||
case "create_notification":
|
||||
return `Notify: ${String(params.message ?? "")}`;
|
||||
default:
|
||||
return action.type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { summarizeActions };
|
||||
|
||||
// ── Rule builder dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DraftCondition {
|
||||
field: AutomationConditionField;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DraftAction {
|
||||
type: AutomationActionType;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
interface RuleBuilderProps {
|
||||
project: Project;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When set, the dialog edits this rule instead of creating a new one. */
|
||||
rule?: AutomationRule | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
function toDraftConditions(conditions: AutomationCondition[]): DraftCondition[] {
|
||||
return conditions.map((c) => ({
|
||||
field: c.field,
|
||||
op: c.op,
|
||||
value: typeof c.value === "string" ? c.value : String(c.value ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
function toDraftActions(actions: AutomationAction[]): DraftAction[] {
|
||||
return actions.map((a) => {
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(a.params ?? {})) {
|
||||
params[key] = typeof value === "string" ? value : String(value ?? "");
|
||||
}
|
||||
return { type: a.type, params };
|
||||
});
|
||||
}
|
||||
|
||||
export function AutomationRuleBuilder({
|
||||
project,
|
||||
open,
|
||||
onOpenChange,
|
||||
rule = null,
|
||||
onSaved,
|
||||
}: RuleBuilderProps) {
|
||||
const [name, setName] = useState(rule?.name ?? "");
|
||||
const [active, setActive] = useState(rule?.active ?? true);
|
||||
const [triggerType, setTriggerType] = useState<AutomationTriggerType>(
|
||||
rule?.trigger.type ?? "task_status_changed"
|
||||
);
|
||||
const [conditions, setConditions] = useState<DraftCondition[]>(
|
||||
toDraftConditions(rule?.conditions ?? [])
|
||||
);
|
||||
const [actions, setActions] = useState<DraftAction[]>(
|
||||
toDraftActions(rule?.actions ?? [])
|
||||
);
|
||||
|
||||
const createMutation = useApiMutation<AutomationRule, Record<string, unknown>>(
|
||||
"post",
|
||||
`/projects/${project.id}/automations`
|
||||
);
|
||||
const updateMutation = useApiMutation<AutomationRule, Record<string, unknown>>(
|
||||
"patch",
|
||||
`/projects/${project.id}/automations/${rule?.id}`
|
||||
);
|
||||
|
||||
const isEditing = Boolean(rule?.id);
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const updateCondition = (index: number, patch: Partial<DraftCondition>) => {
|
||||
setConditions((prev) =>
|
||||
prev.map((c, i) => (i === index ? { ...c, ...patch } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const updateAction = (index: number, patch: Partial<DraftAction>) => {
|
||||
setActions((prev) =>
|
||||
prev.map((a, i) => (i === index ? { ...a, ...patch } : a))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Rule name is required");
|
||||
return;
|
||||
}
|
||||
if (actions.length === 0) {
|
||||
toast.error("Add at least one action");
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop condition rows whose value is still empty.
|
||||
const validConditions = conditions.filter(
|
||||
(c) => typeof c.value === "string" && c.value.trim() !== ""
|
||||
);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
active,
|
||||
trigger: { type: triggerType },
|
||||
conditions: validConditions.map((c) => ({ field: c.field, op: c.op, value: c.value })),
|
||||
actions: actions.map((a) => ({ type: a.type, params: a.params })),
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
toast.success(isEditing ? "Rule updated" : "Rule created");
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
const onError = (err: Error) => toast.error(err.message);
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(payload, { onSuccess, onError });
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess, onError });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit automation rule" : "Create automation rule"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
When something happens to a task, automatically run actions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="rule-name">Rule name</Label>
|
||||
<Input
|
||||
id="rule-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Ship completed tasks"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Switch
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
aria-label="Rule active"
|
||||
/>
|
||||
<Label htmlFor="rule-active" className="cursor-pointer">
|
||||
{active ? "Active" : "Inactive"}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>When</Label>
|
||||
<Select value={triggerType} onValueChange={(v) => setTriggerType(v as AutomationTriggerType)}>
|
||||
<SelectTrigger aria-label="Trigger" className="w-full">
|
||||
<SelectValue placeholder="Select a trigger" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_OPTIONS.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Conditions (optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setConditions((prev) => [
|
||||
...prev,
|
||||
{ field: "status", op: "to", value: project.statuses?.[0]?.key ?? "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add condition
|
||||
</Button>
|
||||
</div>
|
||||
{conditions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No conditions — the rule fires on every matching event.
|
||||
</p>
|
||||
) : (
|
||||
conditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(v) => {
|
||||
const field = v as AutomationConditionField;
|
||||
updateCondition(index, {
|
||||
field,
|
||||
op: opsForField(field)[0]?.value ?? "eq",
|
||||
value: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Condition field" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_FIELD_OPTIONS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={condition.op}
|
||||
onValueChange={(v) => updateCondition(index, { op: v })}
|
||||
>
|
||||
<SelectTrigger aria-label="Condition operator" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opsForField(condition.field).map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{condition.field === "status" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(v) => updateCondition(index, { value: v })}
|
||||
>
|
||||
<SelectTrigger aria-label="Status" className="min-w-0 flex-1">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(project.statuses ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.key}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : condition.field === "priority" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(v) => updateCondition(index, { value: v })}
|
||||
>
|
||||
<SelectTrigger aria-label="Priority" className="min-w-0 flex-1">
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
placeholder="Label name"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setConditions((prev) => prev.filter((_, i) => i !== index))}
|
||||
aria-label="Remove condition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Actions</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setActions((prev) => [
|
||||
...prev,
|
||||
{ type: "set_status", params: { statusKey: project.statuses?.[0]?.key ?? "" } },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add action
|
||||
</Button>
|
||||
</div>
|
||||
{actions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No actions — add at least one.</p>
|
||||
) : (
|
||||
actions.map((action, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={action.type}
|
||||
onValueChange={(v) => {
|
||||
const type = v as AutomationActionType;
|
||||
const defaults: Record<AutomationActionType, Record<string, string>> = {
|
||||
set_status: { statusKey: project.statuses?.[0]?.key ?? "" },
|
||||
set_priority: { priority: "medium" },
|
||||
add_label: { label: "" },
|
||||
create_notification: { message: "" },
|
||||
};
|
||||
updateAction(index, { type, params: defaults[type] });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Action type" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_OPTIONS.map((a) => (
|
||||
<SelectItem key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{action.type === "set_status" ? (
|
||||
<Select
|
||||
value={action.params.statusKey ?? ""}
|
||||
onValueChange={(v) =>
|
||||
updateAction(index, { params: { ...action.params, statusKey: v } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Status" className="min-w-0 flex-1">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(project.statuses ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.key}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : action.type === "set_priority" ? (
|
||||
<Select
|
||||
value={action.params.priority ?? ""}
|
||||
onValueChange={(v) =>
|
||||
updateAction(index, { params: { ...action.params, priority: v } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Priority" className="min-w-0 flex-1">
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : action.type === "add_label" ? (
|
||||
<Input
|
||||
value={action.params.label ?? ""}
|
||||
onChange={(e) =>
|
||||
updateAction(index, { params: { ...action.params, label: e.target.value } })
|
||||
}
|
||||
placeholder="Label name"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={action.params.message ?? ""}
|
||||
onChange={(e) =>
|
||||
updateAction(index, { params: { ...action.params, message: e.target.value } })
|
||||
}
|
||||
placeholder="Notification message"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setActions((prev) => prev.filter((_, i) => i !== index))}
|
||||
aria-label="Remove action"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isEditing ? "Save changes" : "Create rule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Trash2, Bot } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { ErrorState, LoadingState } from "@/components/state";
|
||||
import { api, useApiMutation, useApiQuery } from "@/lib/api";
|
||||
import type { Comment } from "@/lib/types";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
|
||||
interface EntityCommentsProps {
|
||||
entityType: string;
|
||||
@@ -84,20 +85,27 @@ function CommentComposer({
|
||||
entityId,
|
||||
parentId = null,
|
||||
submitLabel = "Comment",
|
||||
placeholder = "Write a comment...",
|
||||
placeholder = "Write a comment... (use @agent to mention)",
|
||||
autoFocus = false,
|
||||
onSubmitted,
|
||||
}: CommentComposerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [content, setContent] = useState("");
|
||||
const showMentionHint = content.includes("@");
|
||||
|
||||
const createMutation = useApiMutation<Comment, CreateCommentVariables>(
|
||||
"post",
|
||||
"/comments",
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (c) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["agent-activity"] });
|
||||
toast.success("Comment added");
|
||||
if (content.includes("@")) {
|
||||
fetch("/api/agents/dispatch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commentId: (c as any).id || "", entityType, entityId, workspaceId: activeDomainId }) }).catch(()=>{});
|
||||
toast.info("Agent notified via @mention");
|
||||
}
|
||||
setContent("");
|
||||
onSubmitted?.();
|
||||
},
|
||||
@@ -127,6 +135,7 @@ function CommentComposer({
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{showMentionHint && <p className="text-[11px] text-muted-foreground flex items-center gap-1"><Bot className="h-3 w-3" /> @mention will notify agents</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={submit} disabled={!canSubmit}>
|
||||
{createMutation.isPending ? "Posting..." : submitLabel}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { addDays, differenceInCalendarDays, endOfDay, format, startOfDay } from "date-fns";
|
||||
import { api } from "@/lib/api";
|
||||
import { getStatusColor } from "@/lib/status-colors";
|
||||
import type { StatusDefinition } from "@/lib/types";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { EmptyState } from "@/components/state";
|
||||
import { GanttTimelineHeader } from "./gantt-timeline-header";
|
||||
import { GanttTaskBar } from "./gantt-task-bar";
|
||||
import { GanttMilestone } from "./gantt-milestone";
|
||||
import { GanttDependencyArrow, type TaskPosition } from "./gantt-dependency-arrow";
|
||||
import {
|
||||
getPixelsPerDay,
|
||||
MILESTONE_BAND_HEIGHT,
|
||||
positionForDate,
|
||||
ROW_HEIGHT,
|
||||
TASK_LIST_WIDTH,
|
||||
TIMELINE_HEADER_HEIGHT,
|
||||
toDayStart,
|
||||
type TimelineMilestone,
|
||||
type TimelineTask,
|
||||
type ZoomLevel,
|
||||
} from "./gantt-utils";
|
||||
|
||||
interface GanttChartProps {
|
||||
domainId: string;
|
||||
projectId: string;
|
||||
tasks: TimelineTask[];
|
||||
milestones: TimelineMilestone[];
|
||||
/** Fallback lookup when the API's joined status is null. */
|
||||
statuses?: StatusDefinition[];
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : "Something went wrong";
|
||||
}
|
||||
|
||||
const GRID_LINE_COLOR = "rgba(148,163,184,0.15)";
|
||||
|
||||
export function GanttChart({ projectId, tasks, milestones, statuses }: GanttChartProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [zoom, setZoom] = useState<ZoomLevel>("week");
|
||||
const pixelsPerDay = getPixelsPerDay(zoom);
|
||||
|
||||
const resolveStatus = (task: TimelineTask): StatusDefinition | null =>
|
||||
task.status ?? statuses?.find((s) => s.id === task.statusId) ?? null;
|
||||
|
||||
const dueMutation = useMutation({
|
||||
mutationFn: ({ taskId, dueDate }: { taskId: string; dueDate: string }) =>
|
||||
api.patch(`/tasks/${taskId}`, { dueDate }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["timeline"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
|
||||
const { viewStart, viewEnd, totalDays, totalWidth, todayX, taskPositions, rowsHeight } = useMemo(() => {
|
||||
const today = startOfDay(new Date());
|
||||
const all: Date[] = [today];
|
||||
for (const t of tasks) {
|
||||
all.push(toDayStart(t.startDate));
|
||||
if (t.dueDate) all.push(toDayStart(t.dueDate));
|
||||
}
|
||||
for (const m of milestones) all.push(toDayStart(m.targetDate));
|
||||
|
||||
const minTime = Math.min(...all.map((d) => d.getTime()));
|
||||
const maxTime = Math.max(...all.map((d) => d.getTime()));
|
||||
const viewStart = startOfDay(addDays(new Date(minTime), -7));
|
||||
const viewEnd = startOfDay(addDays(new Date(maxTime), 7));
|
||||
const totalDays = Math.max(differenceInCalendarDays(viewEnd, viewStart) + 1, 7);
|
||||
const totalWidth = totalDays * pixelsPerDay;
|
||||
|
||||
const positions = new Map<string, TaskPosition>();
|
||||
tasks.forEach((task, i) => {
|
||||
const start = toDayStart(task.startDate);
|
||||
const end = task.dueDate ? toDayStart(task.dueDate) : start;
|
||||
positions.set(task.id, {
|
||||
startX: positionForDate(start, viewStart, pixelsPerDay),
|
||||
endX: positionForDate(end, viewStart, pixelsPerDay),
|
||||
y: i * ROW_HEIGHT + ROW_HEIGHT / 2,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
viewStart,
|
||||
viewEnd,
|
||||
totalDays,
|
||||
totalWidth,
|
||||
todayX: positionForDate(today, viewStart, pixelsPerDay),
|
||||
taskPositions: positions,
|
||||
rowsHeight: tasks.length * ROW_HEIGHT + MILESTONE_BAND_HEIGHT,
|
||||
};
|
||||
}, [tasks, milestones, pixelsPerDay]);
|
||||
|
||||
const gridBackground = `repeating-linear-gradient(to right, ${GRID_LINE_COLOR} 0, ${GRID_LINE_COLOR} 1px, transparent 1px, transparent ${pixelsPerDay}px)`;
|
||||
|
||||
const rows: ReactNode[] = tasks.map((task, i) => {
|
||||
const pos = taskPositions.get(task.id);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="absolute left-0 right-0 border-b border-border/50"
|
||||
style={{ top: i * ROW_HEIGHT, height: ROW_HEIGHT }}
|
||||
>
|
||||
<GanttTaskBar
|
||||
task={task}
|
||||
startX={pos.startX}
|
||||
width={Math.max(pos.endX - pos.startX, 6)}
|
||||
color={getStatusColor(resolveStatus(task))}
|
||||
pixelsPerDay={pixelsPerDay}
|
||||
viewStart={viewStart}
|
||||
onCommit={(taskId, dueDate) => dueMutation.mutate({ taskId, dueDate })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const taskById = new Map(tasks.map((t) => [t.id, t]));
|
||||
const arrows: ReactNode[] = [];
|
||||
for (const task of tasks) {
|
||||
for (const depId of task.dependencies) {
|
||||
const dep = taskById.get(depId);
|
||||
if (dep) {
|
||||
arrows.push(
|
||||
<GanttDependencyArrow
|
||||
key={`${task.id}-${depId}`}
|
||||
fromTask={dep}
|
||||
toTask={task}
|
||||
taskPositions={taskPositions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={zoom}
|
||||
onValueChange={(v) => {
|
||||
if (v) setZoom(v as ZoomLevel);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="day">Day</ToggleGroupItem>
|
||||
<ToggleGroupItem value="week">Week</ToggleGroupItem>
|
||||
<ToggleGroupItem value="month">Month</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(viewStart, "MMM d")} – {format(addDays(viewStart, totalDays - 1), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && milestones.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No timeline data"
|
||||
description="Add tasks or set a milestone target date to see the Gantt view."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-auto rounded-lg border" style={{ maxHeight: "72vh" }}>
|
||||
<div className="flex" style={{ width: TASK_LIST_WIDTH + totalWidth }}>
|
||||
{/* Fixed task list */}
|
||||
<div className="sticky left-0 z-20 shrink-0 border-r bg-background">
|
||||
<div
|
||||
className="sticky top-0 z-30 flex items-center border-b bg-background px-3 text-xs font-semibold text-muted-foreground"
|
||||
style={{ height: TIMELINE_HEADER_HEIGHT }}
|
||||
>
|
||||
Tasks · {tasks.length}
|
||||
</div>
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex h-10 items-center gap-2 border-b px-3">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getStatusColor(resolveStatus(task)) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
||||
className="min-w-0 flex-1 truncate text-left text-xs text-foreground/90 hover:underline"
|
||||
title={task.title}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="flex items-center border-t px-3 text-xs font-semibold text-muted-foreground"
|
||||
style={{ height: MILESTONE_BAND_HEIGHT }}
|
||||
>
|
||||
Milestones · {milestones.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="sticky top-0 z-10 bg-background">
|
||||
<GanttTimelineHeader
|
||||
viewStart={viewStart}
|
||||
viewEnd={addDays(viewStart, totalDays - 1)}
|
||||
zoom={zoom}
|
||||
pixelsPerDay={pixelsPerDay}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative" style={{ height: rowsHeight }}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundImage: gridBackground, backgroundSize: `${pixelsPerDay}px 100%` }}
|
||||
/>
|
||||
{rows}
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t border-border/50 bg-muted/20"
|
||||
style={{ top: tasks.length * ROW_HEIGHT, height: MILESTONE_BAND_HEIGHT }}
|
||||
>
|
||||
{milestones.map((m) => (
|
||||
<GanttMilestone
|
||||
key={m.id}
|
||||
milestone={m}
|
||||
x={positionForDate(m.targetDate, viewStart, pixelsPerDay) - 8}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<svg
|
||||
className="pointer-events-none absolute left-0 top-0 z-10"
|
||||
width={totalWidth}
|
||||
height={rowsHeight}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gantt-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line
|
||||
x1={todayX + 0.5}
|
||||
y1={0}
|
||||
x2={todayX + 0.5}
|
||||
y2={rowsHeight}
|
||||
className="stroke-red-500"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
{arrows}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { TimelineTask } from "./gantt-utils";
|
||||
|
||||
export interface TaskPosition {
|
||||
/** Left edge of the task's bar in timeline pixels. */
|
||||
startX: number;
|
||||
/** Right edge of the task's bar in timeline pixels. */
|
||||
endX: number;
|
||||
/** Vertical center of the task's row in pixels. */
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface GanttDependencyArrowProps {
|
||||
/** The task being depended on (arrow originates at the end of its bar). */
|
||||
fromTask: TimelineTask;
|
||||
/** The blocked task (arrowhead lands at the start of its bar). */
|
||||
toTask: TimelineTask;
|
||||
taskPositions: ReadonlyMap<string, TaskPosition>;
|
||||
}
|
||||
|
||||
const BEND = 12;
|
||||
|
||||
/**
|
||||
* SVG elbow arrow from the end of the blocking task's bar to the start of the
|
||||
* blocked task's bar. Rendered inside the chart's overlay <svg> — the marker
|
||||
* is defined there under the id `gantt-arrow`.
|
||||
*/
|
||||
export function GanttDependencyArrow({ fromTask, toTask, taskPositions }: GanttDependencyArrowProps) {
|
||||
const from = taskPositions.get(fromTask.id);
|
||||
const to = taskPositions.get(toTask.id);
|
||||
if (!from || !to) return null;
|
||||
|
||||
const x1 = from.endX;
|
||||
const y1 = from.y;
|
||||
// If the target bar starts before the source ends, drop the arrowhead just
|
||||
// past the source end so the elbow path never doubles back on itself.
|
||||
const x2 = Math.max(to.startX, from.endX + BEND);
|
||||
const y2 = to.y;
|
||||
const d = `M ${x1} ${y1} H ${x1 + BEND} L ${x2 - BEND} ${y2} H ${x2}`;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#gantt-arrow)"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import type { TimelineMilestone } from "./gantt-utils";
|
||||
|
||||
interface GanttMilestoneProps {
|
||||
milestone: TimelineMilestone;
|
||||
/** Timeline pixel x where the diamond's center should sit. */
|
||||
x: number;
|
||||
}
|
||||
|
||||
/** Diamond marker for a milestone, vertically centered with its date label. */
|
||||
export function GanttMilestone({ milestone, x }: GanttMilestoneProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute flex flex-col items-center"
|
||||
style={{ left: x }}
|
||||
title={`Milestone: ${milestone.name} — ${format(parseISO(milestone.targetDate), "MMM d, yyyy")}`}
|
||||
>
|
||||
<div className="mt-1.5 h-4 w-4 rotate-45 rounded-[2px] border-2 border-background bg-amber-400 shadow-sm" />
|
||||
<span className="mt-1 whitespace-nowrap text-[10px] text-muted-foreground">
|
||||
{format(parseISO(milestone.targetDate), "MMM d")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { addDays, differenceInCalendarDays, format, formatISO, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { positionForDate, toDayStart, type TimelineTask } from "./gantt-utils";
|
||||
|
||||
interface GanttTaskBarProps {
|
||||
task: TimelineTask;
|
||||
/** Left edge of the bar in timeline pixels. */
|
||||
startX: number;
|
||||
/** Bar width in timeline pixels (right edge = startX + width). */
|
||||
width: number;
|
||||
color: string;
|
||||
pixelsPerDay: number;
|
||||
viewStart: Date;
|
||||
onCommit: (taskId: string, dueDate: string) => void;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startClientX: number;
|
||||
origDue: Date;
|
||||
lastDue: Date;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A task bar on the timeline. Tasks only carry an end date (dueDate), so both
|
||||
* dragging the body and pulling the right resize handle move the due date; the
|
||||
* bar stays anchored at its start (createdAt) date. A task without a due date
|
||||
* renders as a small stub that becomes a 1-day bar when dragged.
|
||||
*/
|
||||
export function GanttTaskBar({ task, startX, width, color, pixelsPerDay, viewStart, onCommit }: GanttTaskBarProps) {
|
||||
const [dragDue, setDragDue] = useState<Date | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const startDate = toDayStart(task.startDate);
|
||||
const origDue = task.dueDate ? toDayStart(task.dueDate) : startDate;
|
||||
const endDate = dragDue ?? origDue;
|
||||
const barWidth = Math.max(positionForDate(endDate, viewStart, pixelsPerDay) - startX, 6);
|
||||
const isDone = task.status?.category === "done";
|
||||
const isCancelled = task.status?.category === "cancelled";
|
||||
const title =
|
||||
task.dueDate && task.dueDate !== task.startDate
|
||||
? `${task.title} — due ${format(parseISO(task.dueDate), "MMM d, yyyy")}`
|
||||
: task.title;
|
||||
|
||||
const beginDrag = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const initial = task.dueDate ? toDayStart(task.dueDate) : startDate;
|
||||
dragRef.current = { startClientX: e.clientX, origDue: initial, lastDue: initial, moved: false };
|
||||
setDragDue(initial);
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const state = dragRef.current;
|
||||
if (!state) return;
|
||||
const dx = ev.clientX - state.startClientX;
|
||||
const days = Math.round(dx / pixelsPerDay);
|
||||
let next = addDays(state.origDue, days);
|
||||
if (next < startDate) next = startDate;
|
||||
state.lastDue = next;
|
||||
if (differenceInCalendarDays(next, state.origDue) !== 0) state.moved = true;
|
||||
setDragDue(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
const state = dragRef.current;
|
||||
dragRef.current = null;
|
||||
setDragDue(null);
|
||||
if (state?.moved) onCommit(task.id, formatISO(state.lastDue));
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={beginDrag}
|
||||
className={cn(
|
||||
"absolute top-1.5 h-6 touch-none select-none overflow-hidden rounded-md px-1.5",
|
||||
"text-[11px] font-medium leading-6 text-white shadow-sm",
|
||||
"cursor-grab hover:shadow-md active:cursor-grabbing",
|
||||
isDone && "opacity-60"
|
||||
)}
|
||||
style={{
|
||||
left: startX,
|
||||
width: barWidth,
|
||||
backgroundColor: color,
|
||||
backgroundImage: isCancelled
|
||||
? "repeating-linear-gradient(45deg, transparent 0 4px, rgba(255,255,255,0.35) 4px 8px)"
|
||||
: undefined,
|
||||
}}
|
||||
title={title}
|
||||
aria-label={`${task.title}, drag to change due date`}
|
||||
>
|
||||
{barWidth >= 30 ? <span className="block truncate">{task.title}</span> : null}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize"
|
||||
onPointerDown={beginDrag}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { format, getDaysInMonth, startOfMonth } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getDateRange,
|
||||
positionForDate,
|
||||
TIMELINE_HEADER_HEIGHT,
|
||||
type ZoomLevel,
|
||||
} from "./gantt-utils";
|
||||
|
||||
interface GanttTimelineHeaderProps {
|
||||
viewStart: Date;
|
||||
viewEnd: Date;
|
||||
zoom: ZoomLevel;
|
||||
pixelsPerDay: number;
|
||||
}
|
||||
|
||||
function cellWidth(cell: Date, zoom: ZoomLevel, pixelsPerDay: number): number {
|
||||
switch (zoom) {
|
||||
case "day":
|
||||
return pixelsPerDay;
|
||||
case "week":
|
||||
return 7 * pixelsPerDay;
|
||||
case "month":
|
||||
return getDaysInMonth(cell) * pixelsPerDay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-row date header: a group label row (months for day/week zoom, years for
|
||||
* month zoom) above the per-column cells. Positioned absolutely inside a
|
||||
* container that spans the full timeline width so it can be made sticky by the
|
||||
* parent chart.
|
||||
*/
|
||||
export function GanttTimelineHeader({ viewStart, viewEnd, zoom, pixelsPerDay }: GanttTimelineHeaderProps) {
|
||||
const cells = getDateRange(viewStart, viewEnd, zoom);
|
||||
|
||||
// Group consecutive cells into spans for the top row.
|
||||
const groups: { key: string; label: string; start: Date; end: Date }[] = [];
|
||||
for (const cell of cells) {
|
||||
const key = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "yyyy-MM");
|
||||
const label = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "MMMM yyyy");
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.end = cell;
|
||||
} else {
|
||||
groups.push({ key, label, start: cell, end: cell });
|
||||
}
|
||||
}
|
||||
|
||||
const renderGroup = (group: { key: string; label: string; start: Date; end: Date }) => {
|
||||
const left = positionForDate(group.start, viewStart, pixelsPerDay);
|
||||
const width =
|
||||
positionForDate(group.end, viewStart, pixelsPerDay) +
|
||||
cellWidth(group.end, zoom, pixelsPerDay) -
|
||||
left;
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
className="absolute top-0 h-full overflow-hidden px-2 text-[11px] font-semibold leading-6 text-muted-foreground"
|
||||
style={{ left, width }}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCell = (cell: Date) => {
|
||||
const left = positionForDate(cell, viewStart, pixelsPerDay);
|
||||
const width = cellWidth(cell, zoom, pixelsPerDay);
|
||||
const isWeekend = zoom === "day" && (cell.getDay() === 0 || cell.getDay() === 6);
|
||||
const label =
|
||||
zoom === "day"
|
||||
? format(cell, "EEE d")
|
||||
: zoom === "week"
|
||||
? format(cell, "MMM d")
|
||||
: format(cell, "MMMM");
|
||||
return (
|
||||
<div
|
||||
key={format(cell, "yyyy-MM-dd")}
|
||||
className={cn(
|
||||
"absolute top-0 h-full overflow-hidden border-r border-border/60 px-1.5 text-[11px] leading-8",
|
||||
isWeekend && "bg-muted/50"
|
||||
)}
|
||||
style={{ left, width }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative border-b bg-background" style={{ height: TIMELINE_HEADER_HEIGHT }}>
|
||||
<div className="absolute inset-x-0 top-0 border-b bg-muted/40" style={{ height: 24 }}>
|
||||
{groups.map(renderGroup)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0" style={{ height: 32 }}>
|
||||
{cells.map(renderCell)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
addDays,
|
||||
differenceInCalendarDays,
|
||||
eachDayOfInterval,
|
||||
eachMonthOfInterval,
|
||||
eachWeekOfInterval,
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
parseISO,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns";
|
||||
import type { StatusDefinition } from "@/lib/types";
|
||||
|
||||
export type ZoomLevel = "day" | "week" | "month";
|
||||
|
||||
export interface TimelineTask {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
dueDate: string | null;
|
||||
statusId: string | null;
|
||||
status: StatusDefinition | null;
|
||||
sectionId: string | null;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface TimelineMilestone {
|
||||
id: string;
|
||||
name: string;
|
||||
targetDate: string;
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
tasks: TimelineTask[];
|
||||
milestones: TimelineMilestone[];
|
||||
}
|
||||
|
||||
export const ROW_HEIGHT = 40;
|
||||
export const MILESTONE_BAND_HEIGHT = 48;
|
||||
export const TIMELINE_HEADER_HEIGHT = 56;
|
||||
export const TASK_LIST_WIDTH = 224;
|
||||
|
||||
const PIXELS_PER_DAY: Record<ZoomLevel, number> = {
|
||||
day: 36,
|
||||
week: 12,
|
||||
month: 5,
|
||||
};
|
||||
|
||||
export function getPixelsPerDay(zoom: ZoomLevel): number {
|
||||
return PIXELS_PER_DAY[zoom];
|
||||
}
|
||||
|
||||
/** Normalize a date (or ISO string) to local midnight. */
|
||||
export function toDayStart(date: Date | string): Date {
|
||||
return startOfDay(typeof date === "string" ? parseISO(date) : date);
|
||||
}
|
||||
|
||||
/** Horizontal pixel offset of a date from the view start (local calendar days). */
|
||||
export function positionForDate(date: Date | string, viewStart: Date, pixelsPerDay: number): number {
|
||||
return differenceInCalendarDays(toDayStart(date), startOfDay(viewStart)) * pixelsPerDay;
|
||||
}
|
||||
|
||||
/** Date (local midnight) at a given horizontal pixel offset from the view start. */
|
||||
export function dateForPosition(x: number, viewStart: Date, pixelsPerDay: number): Date {
|
||||
return addDays(startOfDay(viewStart), Math.round(x / pixelsPerDay));
|
||||
}
|
||||
|
||||
/** Column start dates for the timeline header at the given zoom. */
|
||||
export function getDateRange(start: Date, end: Date, zoom: ZoomLevel): Date[] {
|
||||
const s = startOfDay(start);
|
||||
const e = endOfDay(end);
|
||||
switch (zoom) {
|
||||
case "day":
|
||||
return eachDayOfInterval({ start: s, end: e });
|
||||
case "week":
|
||||
return eachWeekOfInterval({ start: startOfWeek(s, { weekStartsOn: 1 }), end: e }, { weekStartsOn: 1 });
|
||||
case "month":
|
||||
return eachMonthOfInterval({ start: startOfMonth(s), end: endOfMonth(e) });
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
ArrowRightLeft,
|
||||
AtSign,
|
||||
Bell,
|
||||
Bot,
|
||||
Check,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
UserPlus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useApiQuery, useApiMutation, api } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { Notification, NotificationCount, NotificationsResponse } from "@/lib/types";
|
||||
|
||||
const NOTIFICATION_META: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
mention: { icon: AtSign, color: "text-blue-500" },
|
||||
status_change: { icon: ArrowRightLeft, color: "text-violet-500" },
|
||||
due_soon: { icon: AlarmClock, color: "text-amber-500" },
|
||||
automation: { icon: Bot, color: "text-emerald-500" },
|
||||
assignment: { icon: UserPlus, color: "text-cyan-500" },
|
||||
};
|
||||
|
||||
/** Navigate to the entity a notification points at. Returns true when a route
|
||||
* was matched (and the sheet should close). */
|
||||
function navigateToEntity(navigate: ReturnType<typeof useNavigate>, n: Notification): boolean {
|
||||
if (!n.entityId || !n.entityType) return false;
|
||||
switch (n.entityType) {
|
||||
case "task":
|
||||
navigate({ to: "/tasks/$id", params: { id: n.entityId } });
|
||||
return true;
|
||||
case "note":
|
||||
navigate({ to: "/notes/$id", params: { id: n.entityId } });
|
||||
return true;
|
||||
case "project":
|
||||
navigate({ to: "/projects/$id", params: { id: n.entityId } });
|
||||
return true;
|
||||
case "habit":
|
||||
navigate({ to: "/habits/$id", params: { id: n.entityId } });
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationCenter() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const domainId = useApiDomain();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Own SSE connection so the badge stays live regardless of which page is
|
||||
// mounted; notification events invalidate the count + list queries.
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const countQuery = useApiQuery<NotificationCount>(
|
||||
["notifications-count", domainId],
|
||||
"/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""),
|
||||
{ enabled: !!domainId, refetchInterval: 30_000 }
|
||||
);
|
||||
const unreadCount = countQuery.data?.count ?? 0;
|
||||
|
||||
const listQuery = useApiQuery<NotificationsResponse>(
|
||||
["notifications", domainId],
|
||||
"/notifications" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}&limit=50` : ""),
|
||||
{ enabled: !!domainId && open }
|
||||
);
|
||||
const notifications = listQuery.data?.items ?? [];
|
||||
const loading = listQuery.isLoading || listQuery.isFetching;
|
||||
|
||||
const invalidateNotifications = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications-count"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
};
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/notifications/${id}`),
|
||||
onMutate: (id) => {
|
||||
// Optimistically decrement the badge so the UI feels instant.
|
||||
queryClient.setQueryData<NotificationCount>(["notifications-count", domainId], (old) =>
|
||||
old && old.count > 0 ? { count: old.count - 1 } : old
|
||||
);
|
||||
queryClient.setQueryData<NotificationsResponse>(["notifications", domainId], (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
items: old.items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)),
|
||||
unreadCount: Math.max(0, old.unreadCount - 1),
|
||||
}
|
||||
: old
|
||||
);
|
||||
return id;
|
||||
},
|
||||
onSuccess: invalidateNotifications,
|
||||
});
|
||||
|
||||
const markAllRead = useApiMutation<{ success: boolean; updated: number }, { workspace_id?: string }>(
|
||||
"post",
|
||||
"/notifications/read-all",
|
||||
{
|
||||
onMutate: () => {
|
||||
queryClient.setQueryData<NotificationCount>(["notifications-count", domainId], (old) =>
|
||||
old ? { count: 0 } : old
|
||||
);
|
||||
queryClient.setQueryData<NotificationsResponse>(["notifications", domainId], (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
items: old.items.map((n) => (n.readAt ? n : { ...n, readAt: new Date().toISOString() })),
|
||||
unreadCount: 0,
|
||||
}
|
||||
: old
|
||||
);
|
||||
},
|
||||
onSuccess: invalidateNotifications,
|
||||
}
|
||||
);
|
||||
|
||||
const handleNotificationClick = (n: Notification) => {
|
||||
if (!n.readAt) markRead.mutate(n.id);
|
||||
if (navigateToEntity(navigate, n)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<SheetTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</SheetTrigger>
|
||||
<TooltipContent>
|
||||
{unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="flex-row items-center justify-between border-b px-4 py-3">
|
||||
<SheetTitle className="text-base">Notifications</SheetTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={unreadCount === 0 || markAllRead.isPending}
|
||||
onClick={() => markAllRead.mutate({ workspace_id: domainId || undefined })}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Mark all read
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Refresh notifications"
|
||||
onClick={() => invalidateNotifications()}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-full flex-1">
|
||||
{loading && notifications.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">Loading notifications…</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="h-8 w-8 opacity-40" />
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{notifications.map((n) => {
|
||||
const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" };
|
||||
const Icon = meta.icon;
|
||||
const unread = !n.readAt;
|
||||
return (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/60 focus:outline-none focus-visible:bg-accent/60 ${
|
||||
unread ? "bg-accent/40" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ${meta.color}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block truncate text-sm ${unread ? "font-semibold" : "font-medium text-muted-foreground"}`}>
|
||||
{n.title}
|
||||
</span>
|
||||
{n.body && (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{n.body}</span>
|
||||
)}
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground/70">
|
||||
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</span>
|
||||
{unread && (
|
||||
<span aria-hidden="true" className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Calendar, Clock, Flag, FolderKanban, Tag as TagIcon, Repeat, CornerDownLeft } from "lucide-react";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { parseQuickAdd, type ParsedTask, type QuickAddContext } from "@/lib/nlp-parser";
|
||||
import { PRIORITY } from "@/lib/status-colors";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project, Tag, StatusDefinition, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
function PreviewChip({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn("gap-1 font-normal text-xs", className)}>
|
||||
{icon}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickAddBar() {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Projects and tags in the active domain, used to resolve #proj / @tag tokens.
|
||||
const { data: projectsData } = useApiQuery<PaginatedResponse<Project>>(
|
||||
["projects", activeDomainId],
|
||||
activeDomainId ? `/projects?limit=200&domain=${activeDomainId}` : "",
|
||||
{ enabled: !!activeDomainId }
|
||||
);
|
||||
const { data: tagsData } = useApiQuery<PaginatedResponse<Tag>>(
|
||||
["tags", activeDomainId],
|
||||
"/tags?perPage=200",
|
||||
{ enabled: !!activeDomainId }
|
||||
);
|
||||
|
||||
const projects = projectsData?.items ?? [];
|
||||
const tags = tagsData?.items ?? [];
|
||||
|
||||
// Context for the parser: the set of known project/tag names.
|
||||
const context: QuickAddContext = useMemo(
|
||||
() => ({ projectNames: projects.map((p) => p.name), tagNames: tags.map((t) => t.name) }),
|
||||
[projects, tags]
|
||||
);
|
||||
|
||||
const parsed = useMemo(() => parseQuickAdd(text, context), [text, context]);
|
||||
|
||||
// Project statuses (to find the "todo" status when a project is selected).
|
||||
const resolvedProject = parsed.project
|
||||
? projects.find((p) => p.name.toLowerCase() === parsed.project!.toLowerCase())
|
||||
: undefined;
|
||||
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||
["project-statuses", resolvedProject?.id ?? "none"],
|
||||
resolvedProject ? `/projects/${resolvedProject.id}/statuses` : "",
|
||||
{ enabled: !!resolvedProject }
|
||||
);
|
||||
const todoStatus = statusesData?.items?.find((s) => s.category === "todo");
|
||||
|
||||
// Keyboard shortcut: `n` (no modifiers, outside editable fields) focuses the
|
||||
// bar. Mirrors the app's single-key shortcut pattern (?, /, c).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
e.metaKey || e.ctrlKey || e.altKey ||
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "n") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const canSubmit = parsed.title.trim().length > 0 && !isSubmitting;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Resolve matched names back to ids for the API payload.
|
||||
const tagIds = parsed.tags
|
||||
?.map((name) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.id)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
await api.post("/tasks", {
|
||||
title: parsed.title,
|
||||
...(parsed.priority ? { priority: parsed.priority } : {}),
|
||||
...(resolvedProject ? { projectId: resolvedProject.id } : {}),
|
||||
...(parsed.dueDate ? { dueDate: parsed.dueDate.toISOString() } : {}),
|
||||
...(parsed.recurrence ? { recurrenceRule: parsed.recurrence } : {}),
|
||||
...(todoStatus ? { statusId: todoStatus.id } : {}),
|
||||
...(tagIds && tagIds.length > 0 ? { tagIds } : {}),
|
||||
...(activeDomainId ? { domain: activeDomainId } : {}),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setText("");
|
||||
toast.success("Created!");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "Failed to create task");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Preview chips describing what the parser detected.
|
||||
const chips: React.ReactNode[] = [];
|
||||
if (parsed.dueDate) {
|
||||
chips.push(
|
||||
<PreviewChip
|
||||
key="due"
|
||||
icon={<Calendar className="h-3 w-3" />}
|
||||
label={parsed.dueDate.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}) + (parsed.dueDate.getHours() || parsed.dueDate.getMinutes()
|
||||
? ` ${parsed.dueDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||
: "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (parsed.priority) {
|
||||
chips.push(
|
||||
<PreviewChip
|
||||
key="prio"
|
||||
icon={<Flag className="h-3 w-3" />}
|
||||
label={PRIORITY[parsed.priority]?.label ?? parsed.priority}
|
||||
className="text-orange-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resolvedProject) {
|
||||
chips.push(
|
||||
<PreviewChip
|
||||
key="proj"
|
||||
icon={<FolderKanban className="h-3 w-3" />}
|
||||
label={resolvedProject.name}
|
||||
className="text-violet-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (parsed.tags?.length) {
|
||||
for (const tag of parsed.tags) {
|
||||
chips.push(
|
||||
<PreviewChip
|
||||
key={`tag-${tag}`}
|
||||
icon={<TagIcon className="h-3 w-3" />}
|
||||
label={tag}
|
||||
className="text-sky-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (parsed.recurrence) {
|
||||
chips.push(
|
||||
<PreviewChip key="rec" icon={<Repeat className="h-3 w-3" />} label="Recurring" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 flex justify-center px-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="pointer-events-auto w-full max-w-xl rounded-xl border bg-background/95 shadow-lg backdrop-blur"
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Quick add: 'buy milk tomorrow !high #work'"
|
||||
className="h-9 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
aria-label="Quick add task"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 gap-1"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Add
|
||||
<CornerDownLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{focused && (chips.length > 0 || text.trim().length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t px-3 py-2">
|
||||
{chips.length > 0 ? (
|
||||
chips
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<kbd className="rounded border bg-muted px-1">!high</kbd> priority ·{" "}
|
||||
<kbd className="rounded border bg-muted px-1">#project</kbd> ·{" "}
|
||||
<kbd className="rounded border bg-muted px-1">@tag</kbd> ·{" "}
|
||||
<kbd className="rounded border bg-muted px-1">tomorrow</kbd>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Share2,
|
||||
LayoutGrid,
|
||||
CalendarDays,
|
||||
Search,
|
||||
BarChart3,
|
||||
@@ -67,7 +66,6 @@ const navItems: NavItem[] = [
|
||||
{ href: "/search", label: "Search", icon: Search },
|
||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||
{ href: "/agents/activity", label: "Agent Activity", icon: Bot },
|
||||
{ href: "/canvas", label: "Canvas", icon: LayoutGrid },
|
||||
{ href: "/daily", label: "Daily Notes", icon: PenLine },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Search, Plus, Menu } from "lucide-react";
|
||||
import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||
import { NotificationCenter } from "@/components/notification-center";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,10 +21,27 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { NotificationsResponse } from "@/lib/types";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
deleted: "deleted",
|
||||
completed: "completed",
|
||||
};
|
||||
|
||||
function readableEntityType(entityType: string): string {
|
||||
return entityType
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { setMobileOpen } = useSidebarStore();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const domainId = useApiDomain();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userName = user?.name || "User";
|
||||
const userEmail = user?.email || "";
|
||||
@@ -32,6 +51,20 @@ export function Topbar() {
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
};
|
||||
|
||||
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
|
||||
["notifications", domainId],
|
||||
"/notifications?workspace_id=" + encodeURIComponent(domainId),
|
||||
{ enabled: !!domainId, refetchInterval: 60_000 }
|
||||
);
|
||||
|
||||
const notifications = notificationsData?.items || [];
|
||||
const count = notificationsData?.count || 0;
|
||||
const badgeLabel = count > 99 ? "99+" : String(count);
|
||||
const tooltipText =
|
||||
count === 0
|
||||
? "No notifications"
|
||||
: `${count} unread notification${count === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
||||
@@ -86,15 +119,67 @@ export function Topbar() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Notifications bell (badge + slide-out panel) */}
|
||||
<NotificationCenter />
|
||||
{/* Notifications bell */}
|
||||
<TooltipProvider>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
|
||||
<span className="text-sm capitalize">
|
||||
{readableEntityType(n.entityType)}{" "}
|
||||
{ACTION_LABELS[n.action] || n.action}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{n.actor} · {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* User avatar */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { RRule } from "rrule";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const PRESETS = [
|
||||
{ value: "", label: "No repeat" },
|
||||
{ value: "FREQ=DAILY", label: "Daily" },
|
||||
{ value: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", label: "Weekdays" },
|
||||
{ value: "FREQ=WEEKLY", label: "Weekly" },
|
||||
{ value: "FREQ=WEEKLY;BYDAY=MO", label: "Weekly on Monday" },
|
||||
{ value: "FREQ=MONTHLY", label: "Monthly" },
|
||||
{ value: "FREQ=YEARLY", label: "Yearly" },
|
||||
];
|
||||
|
||||
export function RecurrencePicker({ value, onChange }: { value: string | null; onChange: (v: string | null) => void }) {
|
||||
const [preset, setPreset] = useState(value || "");
|
||||
const preview = useMemo(() => {
|
||||
if (!preset) return [];
|
||||
try {
|
||||
const rule = RRule.fromString(preset);
|
||||
const opts = { ...rule.options, dtstart: new Date() };
|
||||
const r = new RRule(opts);
|
||||
return r.all((_, i) => i < 5).map(d => d.toLocaleDateString());
|
||||
} catch { return []; }
|
||||
}, [preset]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Repeat</Label>
|
||||
<Select value={preset} onValueChange={(v) => { setPreset(v); onChange(v || null); }}>
|
||||
<SelectTrigger><SelectValue placeholder="No repeat" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{preview.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Next: {preview.join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,9 +52,6 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
case "agent":
|
||||
queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]);
|
||||
break;
|
||||
case "canvas":
|
||||
queryKeys.push(["canvas"]);
|
||||
break;
|
||||
case "webhook":
|
||||
queryKeys.push(["webhooks"]);
|
||||
break;
|
||||
@@ -72,10 +69,6 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
case "graph_edge":
|
||||
queryKeys.push(["graph"]);
|
||||
break;
|
||||
case "notification":
|
||||
// A new in-app notification landed — refresh the bell badge and list.
|
||||
queryKeys.push(["notifications"], ["notifications-count"]);
|
||||
break;
|
||||
default:
|
||||
queryKeys.push([entityType]);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { parseQuickAdd } from "./nlp-parser";
|
||||
|
||||
// Deterministic "now" so date math is stable across runs. Tests that rely on
|
||||
// calendar dates are anchored relative to this fixed reference point.
|
||||
const NOW = new Date("2026-08-19T12:00:00"); // a Wednesday
|
||||
|
||||
function parse(input: string, context?: Parameters<typeof parseQuickAdd>[1]) {
|
||||
return parseQuickAdd(input, context);
|
||||
}
|
||||
|
||||
// Helper: same calendar day check regardless of time component.
|
||||
function sameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
// Patch Date.now for the tests that compute relative dates.
|
||||
describe("nlp-parser", () => {
|
||||
const realNow = Date.now;
|
||||
beforeEach(() => {
|
||||
Date.now = () => NOW.getTime();
|
||||
});
|
||||
afterEach(() => {
|
||||
Date.now = realNow;
|
||||
});
|
||||
|
||||
describe("title extraction", () => {
|
||||
it("keeps plain text as the title", () => {
|
||||
const r = parse("buy milk");
|
||||
expect(r.title).toBe("buy milk");
|
||||
});
|
||||
|
||||
it("removes recognized tokens from the title", () => {
|
||||
const r = parse("buy milk tomorrow !high");
|
||||
expect(r.title).toBe("buy milk");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from the title", () => {
|
||||
const r = parse(" buy milk tomorrow ");
|
||||
expect(r.title).toBe("buy milk");
|
||||
});
|
||||
|
||||
it("keeps multiple words in original order", () => {
|
||||
const r = parse("fix login bug in the auth flow");
|
||||
expect(r.title).toBe("fix login bug in the auth flow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority", () => {
|
||||
it("parses !urgent", () => {
|
||||
expect(parse("ship !urgent").priority).toBe("urgent");
|
||||
});
|
||||
it("parses !high", () => {
|
||||
expect(parse("ship !high").priority).toBe("high");
|
||||
});
|
||||
it("parses !medium", () => {
|
||||
expect(parse("ship !medium").priority).toBe("medium");
|
||||
});
|
||||
it("parses !low", () => {
|
||||
expect(parse("ship !low").priority).toBe("low");
|
||||
});
|
||||
it("parses !! as urgent", () => {
|
||||
expect(parse("ship !!").priority).toBe("urgent");
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(parse("ship !HIGH").priority).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dates", () => {
|
||||
it("parses tomorrow as next day", () => {
|
||||
const r = parse("buy milk tomorrow");
|
||||
expect(r.dueDate).toBeDefined();
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses in 3 days", () => {
|
||||
const r = parse("fix bug in 3 days");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses in 2 weeks", () => {
|
||||
const r = parse("plan in 2 weeks");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 8, 2))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses next friday", () => {
|
||||
// NOW is Wed 2026-08-19; next friday is 2026-08-21.
|
||||
const r = parse("review PR next friday");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses a bare weekday as the next occurrence", () => {
|
||||
// NOW is Wed 2026-08-19; next monday is 2026-08-24.
|
||||
const r = parse("standup monday");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 24))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses ISO date 2025-01-15", () => {
|
||||
const r = parse("deadline 2025-01-15");
|
||||
expect(sameDay(r.dueDate!, new Date(2025, 0, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses month name + day (dec 25)", () => {
|
||||
const r = parse("gift dec 25");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 11, 25))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses end of month", () => {
|
||||
const r = parse("report end of month");
|
||||
// Aug 2026 has 31 days.
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 31))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses next week", () => {
|
||||
const r = parse("event next week");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 26))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("times", () => {
|
||||
it("parses at 2pm on a date", () => {
|
||||
const r = parse("call tomorrow at 2pm");
|
||||
expect(r.dueDate!.getHours()).toBe(14);
|
||||
expect(r.dueDate!.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it("parses at 9am on a date", () => {
|
||||
const r = parse("standup tomorrow at 9am");
|
||||
expect(r.dueDate!.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it("parses 24h time at 14:30", () => {
|
||||
const r = parse("meeting tomorrow at 14:30");
|
||||
expect(r.dueDate!.getHours()).toBe(14);
|
||||
expect(r.dueDate!.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it("removes the time phrase from the title", () => {
|
||||
const r = parse("call tomorrow at 2pm");
|
||||
expect(r.title).toBe("call");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projects and tags", () => {
|
||||
it("resolves #project to a known project name", () => {
|
||||
const r = parse("buy milk #work", { projectNames: ["Work", "Personal"] });
|
||||
expect(r.project).toBe("Work");
|
||||
});
|
||||
|
||||
it("resolves @tag to a known tag name", () => {
|
||||
const r = parse("task @sarah", { tagNames: ["sarah", "billing"] });
|
||||
expect(r.tags).toEqual(["sarah"]);
|
||||
});
|
||||
|
||||
it("keeps unknown #project in the title", () => {
|
||||
const r = parse("fix #hashtag bug", { projectNames: ["Work"] });
|
||||
expect(r.project).toBeUndefined();
|
||||
expect(r.title).toContain("#hashtag");
|
||||
});
|
||||
|
||||
it("keeps unknown @tag in the title", () => {
|
||||
const r = parse("mention @nobody", { tagNames: ["sarah"] });
|
||||
expect(r.tags).toBeUndefined();
|
||||
expect(r.title).toContain("@nobody");
|
||||
});
|
||||
|
||||
it("resolves multiple tags", () => {
|
||||
const r = parse("task @a @b", { tagNames: ["a", "b", "c"] });
|
||||
expect(r.tags).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurrence", () => {
|
||||
it("parses daily", () => {
|
||||
expect(parse("standup daily").recurrence).toBe("FREQ=DAILY");
|
||||
});
|
||||
it("parses weekly", () => {
|
||||
expect(parse("review weekly").recurrence).toBe("FREQ=WEEKLY");
|
||||
});
|
||||
it("parses monthly", () => {
|
||||
expect(parse("report monthly").recurrence).toBe("FREQ=MONTHLY");
|
||||
});
|
||||
it("parses every monday", () => {
|
||||
expect(parse("standup every monday").recurrence).toBe("FREQ=WEEKLY;BYDAY=MO");
|
||||
});
|
||||
it("parses every 2 weeks", () => {
|
||||
expect(parse("review every 2 weeks").recurrence).toBe("FREQ=WEEKLY;INTERVAL=2");
|
||||
});
|
||||
it("parses every month on the 15th", () => {
|
||||
expect(parse("bill every month on the 15th").recurrence).toBe(
|
||||
"FREQ=MONTHLY;BYMONTHDAY=15"
|
||||
);
|
||||
});
|
||||
it("removes recurrence words from the title", () => {
|
||||
const r = parse("standup every monday");
|
||||
expect(r.title).toBe("standup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined & edge cases", () => {
|
||||
it("parses a full example", () => {
|
||||
const r = parse("buy milk tomorrow !high #work @sarah every monday", {
|
||||
projectNames: ["work"],
|
||||
tagNames: ["sarah"],
|
||||
});
|
||||
expect(r.title).toBe("buy milk");
|
||||
expect(r.priority).toBe("high");
|
||||
expect(r.project).toBe("work");
|
||||
expect(r.tags).toEqual(["sarah"]);
|
||||
expect(r.recurrence).toBe("FREQ=WEEKLY;BYDAY=MO");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses review PR !urgent next friday", () => {
|
||||
const r = parse("review PR !urgent next friday");
|
||||
expect(r.title).toBe("review PR");
|
||||
expect(r.priority).toBe("urgent");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses team standup daily at 9am #engineering", () => {
|
||||
const r = parse("team standup daily at 9am #engineering", {
|
||||
projectNames: ["engineering"],
|
||||
});
|
||||
expect(r.title).toBe("team standup");
|
||||
expect(r.recurrence).toBe("FREQ=DAILY");
|
||||
expect(r.dueDate!.getHours()).toBe(9);
|
||||
expect(r.project).toBe("engineering");
|
||||
});
|
||||
|
||||
it("parses fix login bug in 3 days !high #backend", () => {
|
||||
const r = parse("fix login bug in 3 days !high #backend", {
|
||||
projectNames: ["backend"],
|
||||
});
|
||||
expect(r.title).toBe("fix login bug");
|
||||
expect(r.priority).toBe("high");
|
||||
expect(r.project).toBe("backend");
|
||||
expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the raw input", () => {
|
||||
const input = "buy milk tomorrow !high";
|
||||
expect(parse(input).raw).toBe(input);
|
||||
});
|
||||
|
||||
it("returns empty title for only-metadata input", () => {
|
||||
const r = parse("!high tomorrow", { projectNames: [] });
|
||||
expect(r.title).toBe("");
|
||||
expect(r.priority).toBe("high");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,458 +0,0 @@
|
||||
/**
|
||||
* Natural-language quick-add parser.
|
||||
*
|
||||
* A pure, dependency-light tokenizer that turns free-text task input into
|
||||
* structured task data:
|
||||
*
|
||||
* "buy milk tomorrow !high #work @sarah every monday"
|
||||
* → title: "buy milk", dueDate: tomorrow, priority: "high",
|
||||
* project: "work" (resolved against context), tags: ["sarah"],
|
||||
* recurrence: "FREQ=WEEKLY;BYDAY=MO"
|
||||
*
|
||||
* No network calls, no external NLP library — just regex tokenization over a
|
||||
* normalized token stream, evaluated in the browser's local timezone.
|
||||
*/
|
||||
|
||||
export type QuickAddPriority = "low" | "medium" | "high" | "urgent";
|
||||
|
||||
export interface ParsedTask {
|
||||
/** The remaining free text with all recognized tokens removed. */
|
||||
title: string;
|
||||
/** Resolved absolute due date (local timezone), if one was given. */
|
||||
dueDate?: Date;
|
||||
priority?: QuickAddPriority;
|
||||
/**
|
||||
* The matched project NAME (from `#project`), when it matches a known name
|
||||
* in `context.projectNames`. The caller maps this name to an id before
|
||||
* sending the create request.
|
||||
*/
|
||||
project?: string;
|
||||
/** Matched tag NAMES (from `@label`), when they match `context.tagNames`. */
|
||||
tags?: string[];
|
||||
/** An RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO"). */
|
||||
recurrence?: string;
|
||||
/** The original, unmodified input string. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface QuickAddContext {
|
||||
projectNames?: string[];
|
||||
tagNames?: string[];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Strip leading zeros so "02" reads as "2" (used for ordinal date math). */
|
||||
function num(n: string): number {
|
||||
return parseInt(n.replace(/^0+/, "") || "0", 10);
|
||||
}
|
||||
|
||||
/** Start-of-day in the local timezone. All date tokens are anchored to this. */
|
||||
function startOfDay(d: Date): Date {
|
||||
const copy = new Date(d);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const copy = new Date(d);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function addMonths(d: Date, months: number): Date {
|
||||
const copy = new Date(d);
|
||||
copy.setMonth(copy.getMonth() + months);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Next occurrence of `weekday` (0=Sun..6=Sat). When includeToday, today counts. */
|
||||
function nextWeekday(from: Date, weekday: number, includeToday: boolean): Date {
|
||||
let d = startOfDay(from);
|
||||
if (!includeToday) d = addDays(d, 1);
|
||||
while (d.getDay() !== weekday) d = addDays(d, 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Tokenization ──────────────────────────────────────────────────────────────
|
||||
|
||||
type TokenKind =
|
||||
| "word"
|
||||
| "priority"
|
||||
| "project"
|
||||
| "tag"
|
||||
| "date"
|
||||
| "time"
|
||||
| "recurrence";
|
||||
|
||||
interface Token {
|
||||
kind: TokenKind;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break the input into a token stream, tagging each token with its kind.
|
||||
* Plain words are kept verbatim (the title is rebuilt from them in order).
|
||||
*/
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
const re =
|
||||
/(\*\*)|(!urgent|!high|!medium|!low)|(#[^\s]+)|(@[^\s]+)|(\d{4}-\d{2}-\d{2})|(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)|([0-2]?\d:\d{2}\s?(?:am|pm)?)|([^\s]+)/gi;
|
||||
|
||||
for (const m of input.matchAll(re)) {
|
||||
const full = m[0];
|
||||
if (!full) continue;
|
||||
|
||||
if (/^!!$/.test(full)) {
|
||||
tokens.push({ kind: "priority", value: "urgent" });
|
||||
} else if (/^!(urgent|high|medium|low)$/i.test(full)) {
|
||||
tokens.push({ kind: "priority", value: full.slice(1).toLowerCase() });
|
||||
} else if (/^#[^\s]+$/.test(full)) {
|
||||
tokens.push({ kind: "project", value: full.slice(1) });
|
||||
} else if (/^@[^\s]+$/.test(full)) {
|
||||
tokens.push({ kind: "tag", value: full.slice(1) });
|
||||
} else if (/^\d{4}-\d{2}-\d{2}$/.test(full) || /^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(full)) {
|
||||
tokens.push({ kind: "date", value: full });
|
||||
} else if (/^[0-2]?\d:\d{2}\s?(?:am|pm)?$/i.test(full)) {
|
||||
tokens.push({ kind: "time", value: full });
|
||||
} else {
|
||||
tokens.push({ kind: "word", value: full });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Recurrence parsing ────────────────────────────────────────────────────────
|
||||
|
||||
const WEEKDAY_MAP: Record<string, number> = {
|
||||
sun: 0, sunday: 0,
|
||||
mon: 1, monday: 1,
|
||||
tue: 2, tues: 2, tuesday: 2,
|
||||
wed: 3, wednesday: 3,
|
||||
thu: 4, thur: 4, thurs: 4, thursday: 4,
|
||||
fri: 5, friday: 5,
|
||||
sat: 6, saturday: 6,
|
||||
};
|
||||
|
||||
const MONTH_MAP: Record<string, number> = {
|
||||
jan: 0, january: 0,
|
||||
feb: 1, february: 1,
|
||||
mar: 2, march: 2,
|
||||
apr: 3, april: 3,
|
||||
may: 4,
|
||||
jun: 5, june: 5,
|
||||
jul: 6, july: 6,
|
||||
aug: 7, august: 7,
|
||||
sep: 8, sept: 8, september: 8,
|
||||
oct: 9, october: 9,
|
||||
nov: 10, november: 10,
|
||||
dec: 11, december: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a recurrence phrase into an RRULE (RFC 5545), or null if the window
|
||||
* does not start with a recurrence. Returns the rule and how many words it
|
||||
* consumed.
|
||||
*/
|
||||
function parseRecurrence(words: string[]): { rrule: string; consumed: number } | null {
|
||||
const low = words.map((w) => w.toLowerCase());
|
||||
|
||||
if (low[0] === "daily") return { rrule: "FREQ=DAILY", consumed: 1 };
|
||||
if (low[0] === "weekly") return { rrule: "FREQ=WEEKLY", consumed: 1 };
|
||||
if (low[0] === "monthly") return { rrule: "FREQ=MONTHLY", consumed: 1 };
|
||||
if (low[0] === "yearly") return { rrule: "FREQ=YEARLY", consumed: 1 };
|
||||
|
||||
if (low[0] === "every") {
|
||||
let i = 1;
|
||||
let interval = 1;
|
||||
if (/^\d+$/.test(low[i] ?? "")) {
|
||||
interval = num(low[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Omit INTERVAL when it's the default 1 to keep rules concise.
|
||||
const intervalPart = interval !== 1 ? `;INTERVAL=${interval}` : "";
|
||||
const unit = low[i];
|
||||
if (unit === "day" || unit === "days") return { rrule: `FREQ=DAILY${intervalPart}`, consumed: i + 1 };
|
||||
if (unit === "week" || unit === "weeks") return { rrule: `FREQ=WEEKLY${intervalPart}`, consumed: i + 1 };
|
||||
if (unit === "month" || unit === "months") {
|
||||
// "every month on the 15th"
|
||||
if (
|
||||
(low[i + 1] === "on" && low[i + 2] === "the" && /^(\d+)(st|nd|rd|th)?$/.test(low[i + 3] ?? ""))
|
||||
) {
|
||||
const day = num(low[i + 3]);
|
||||
if (day >= 1 && day <= 31) {
|
||||
return { rrule: `FREQ=MONTHLY${intervalPart};BYMONTHDAY=${day}`, consumed: i + 4 };
|
||||
}
|
||||
}
|
||||
return { rrule: `FREQ=MONTHLY${intervalPart}`, consumed: i + 1 };
|
||||
}
|
||||
if (unit === "year" || unit === "years") return { rrule: `FREQ=YEARLY${intervalPart}`, consumed: i + 1 };
|
||||
|
||||
if (WEEKDAY_MAP[unit] !== undefined) {
|
||||
const byday = unit.slice(0, 2).toUpperCase();
|
||||
return { rrule: `FREQ=WEEKLY${intervalPart};BYDAY=${byday}`, consumed: i + 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Date & time parsing ───────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a single-word date token (ISO date or slash date). */
|
||||
function parseSingleWordDate(word: string, now: Date): Date | null {
|
||||
const low = word.toLowerCase();
|
||||
|
||||
const iso = low.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (iso) {
|
||||
const y = num(iso[1]);
|
||||
const m = num(iso[2]) - 1;
|
||||
const d = num(iso[3]);
|
||||
const date = new Date(y, m, d, 0, 0, 0, 0);
|
||||
if (!isNaN(date.getTime()) && date.getFullYear() === y && date.getMonth() === m && date.getDate() === d) {
|
||||
return date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const slash = low.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
||||
if (slash) {
|
||||
const a = num(slash[1]);
|
||||
const b = num(slash[2]);
|
||||
if (slash[3]) {
|
||||
let y = num(slash[3]);
|
||||
if (y < 100) y += 2000;
|
||||
const date = new Date(y, a - 1, b, 0, 0, 0, 0);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
// MM/DD — next future occurrence
|
||||
let date = new Date(now.getFullYear(), a - 1, b, 0, 0, 0, 0);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, a - 1, b, 0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a "dec 25" phrase (month name + day). Consumes 2 words. */
|
||||
function parseMonthDay(words: string[], now: Date): { date: Date; consumed: number } | null {
|
||||
const low = words.map((w) => w.toLowerCase());
|
||||
if (MONTH_MAP[low[0]] !== undefined && /^(\d{1,2})(st|nd|rd|th)?$/.test(low[1] ?? "")) {
|
||||
const day = num(low[1]);
|
||||
if (day < 1 || day > 31) return null;
|
||||
const month = MONTH_MAP[low[0]];
|
||||
let date = new Date(now.getFullYear(), month, day, 0, 0, 0, 0);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, month, day, 0, 0, 0, 0);
|
||||
return { date, consumed: 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a multi-word date phrase ("tomorrow", "next friday", "in 3 days",
|
||||
* "end of month"). Returns the date plus how many words were consumed.
|
||||
*/
|
||||
function parseDatePhrase(
|
||||
words: string[],
|
||||
now: Date
|
||||
): { date: Date; consumed: number } | null {
|
||||
const low = words.map((w) => w.toLowerCase());
|
||||
|
||||
if (low[0] === "tomorrow") return { date: addDays(startOfDay(now), 1), consumed: 1 };
|
||||
if (low[0] === "today" || low[0] === "tonight") return { date: startOfDay(now), consumed: 1 };
|
||||
|
||||
if (low[0] === "in" && /^\d+$/.test(low[1] ?? "")) {
|
||||
const n = num(low[1]);
|
||||
if (low[2] === "days" || low[2] === "day") return { date: addDays(startOfDay(now), n), consumed: 3 };
|
||||
if (low[2] === "weeks" || low[2] === "week") return { date: addDays(startOfDay(now), n * 7), consumed: 3 };
|
||||
if (low[2] === "months" || low[2] === "month") return { date: addMonths(startOfDay(now), n), consumed: 3 };
|
||||
if (low[2] === "hours" || low[2] === "hour") return { date: new Date(now.getTime() + n * 3600 * 1000), consumed: 3 };
|
||||
}
|
||||
|
||||
if ((low[0] === "next" || low[0] === "this") && WEEKDAY_MAP[low[1] ?? ""] !== undefined) {
|
||||
return { date: nextWeekday(now, WEEKDAY_MAP[low[1]], low[0] === "this"), consumed: 2 };
|
||||
}
|
||||
|
||||
if (low[0] === "next" && low[1] === "week") return { date: addDays(startOfDay(now), 7), consumed: 2 };
|
||||
if (low[0] === "next" && low[1] === "month") return { date: addMonths(startOfDay(now), 1), consumed: 2 };
|
||||
|
||||
if (low[0] === "end" && low[1] === "of") {
|
||||
if (low[2] === "month") {
|
||||
const sod = startOfDay(now);
|
||||
return { date: new Date(sod.getFullYear(), sod.getMonth() + 1, 0), consumed: 3 };
|
||||
}
|
||||
if (low[2] === "week") return { date: nextWeekday(now, 6, false), consumed: 3 };
|
||||
if (low[2] === "day") return { date: startOfDay(now), consumed: 3 };
|
||||
}
|
||||
|
||||
if (WEEKDAY_MAP[low[0]] !== undefined) {
|
||||
return { date: nextWeekday(now, WEEKDAY_MAP[low[0]], false), consumed: 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a time token ("2pm", "14:30", "9am") into hours/minutes. */
|
||||
function parseTime(word: string): { hours: number; minutes: number } | null {
|
||||
const colon = word.toLowerCase().match(/^([0-2]?\d):(\d{2})\s?(am|pm)?$/);
|
||||
if (colon) {
|
||||
let h = num(colon[1]);
|
||||
const min = num(colon[2]);
|
||||
const ampm = colon[3];
|
||||
if (ampm === "pm" && h < 12) h += 12;
|
||||
if (ampm === "am" && h === 12) h = 0;
|
||||
if (h > 23 || min > 59) return null;
|
||||
return { hours: h, minutes: min };
|
||||
}
|
||||
const bare = word.toLowerCase().match(/^(\d{1,2})(am|pm)$/);
|
||||
if (bare) {
|
||||
let h = num(bare[1]);
|
||||
if (bare[2] === "pm" && h < 12) h += 12;
|
||||
if (bare[2] === "am" && h === 12) h = 0;
|
||||
if (h > 23) return null;
|
||||
return { hours: h, minutes: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Main parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a quick-add string into structured task data.
|
||||
*
|
||||
* `context` supplies the user's known project/tag names so `#proj` and `@tag`
|
||||
* tokens can be matched (the matched NAME is returned; the caller resolves it
|
||||
* to an id). Unknown tokens are left in the title so nothing is silently lost.
|
||||
*/
|
||||
export function parseQuickAdd(input: string, context?: QuickAddContext): ParsedTask {
|
||||
const tokens = tokenize(input);
|
||||
// Date.now() (rather than `new Date()`) so tests can pin "now" by patching
|
||||
// Date.now; the production path is unaffected.
|
||||
const now = new Date(Date.now());
|
||||
|
||||
let priority: ParsedTask["priority"];
|
||||
let project: string | undefined;
|
||||
const tags: string[] = [];
|
||||
let dueDate: Date | undefined;
|
||||
let recurrence: string | undefined;
|
||||
// Track which project/tag tokens were resolved so unmatched ones stay in the
|
||||
// title instead of being silently dropped.
|
||||
const resolvedProjects = new Set<string>();
|
||||
const resolvedTags = new Set<string>();
|
||||
|
||||
const words = tokens.map((t) => t.value);
|
||||
|
||||
// Pass 1: recurrences (multi-word, e.g. "every monday"). Run before dates so
|
||||
// a bare weekday inside "every monday" is not mistaken for a one-off date.
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
if (tokens[i].kind === "word") {
|
||||
const rec = parseRecurrence(words.slice(i, i + 6));
|
||||
if (rec) {
|
||||
recurrence = rec.rrule;
|
||||
for (let k = i; k < i + rec.consumed; k++) tokens[k] = { kind: "recurrence", value: tokens[k].value };
|
||||
i += rec.consumed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Pass 2: everything else.
|
||||
i = 0;
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
|
||||
if (t.kind === "priority") {
|
||||
priority = t.value as ParsedTask["priority"];
|
||||
} else if (t.kind === "project") {
|
||||
const match = context?.projectNames?.find((p) => p.toLowerCase() === t.value.toLowerCase());
|
||||
if (match) {
|
||||
project = match;
|
||||
resolvedProjects.add(t.value);
|
||||
}
|
||||
} else if (t.kind === "tag") {
|
||||
const match = context?.tagNames?.find((tg) => tg.toLowerCase() === t.value.toLowerCase());
|
||||
if (match) {
|
||||
tags.push(match);
|
||||
resolvedTags.add(t.value);
|
||||
}
|
||||
} else if (t.kind === "date") {
|
||||
const parsed = parseSingleWordDate(t.value, now);
|
||||
if (parsed) dueDate = parsed;
|
||||
} else if (t.kind === "time") {
|
||||
const parsed = parseTime(t.value);
|
||||
if (parsed) {
|
||||
const base = dueDate ? new Date(dueDate) : startOfDay(now);
|
||||
base.setHours(parsed.hours, parsed.minutes, 0, 0);
|
||||
dueDate = base;
|
||||
}
|
||||
} else if (t.kind === "word") {
|
||||
const window = words.slice(i, i + 4);
|
||||
|
||||
// "at 2pm" — apply the time to the resolved (or today's) due date.
|
||||
if (window[0] === "at" && parseTime(window[1] ?? "")) {
|
||||
const time = parseTime(window[1])!;
|
||||
const base = dueDate ? new Date(dueDate) : startOfDay(now);
|
||||
base.setHours(time.hours, time.minutes, 0, 0);
|
||||
dueDate = base;
|
||||
tokens[i] = { kind: "time", value: window[0] };
|
||||
tokens[i + 1] = { kind: "time", value: window[1] };
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const monthDay = parseMonthDay(window, now);
|
||||
if (monthDay) {
|
||||
dueDate = monthDay.date;
|
||||
for (let k = i; k < i + monthDay.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value };
|
||||
i += monthDay.consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const phrase = parseDatePhrase(window, now);
|
||||
if (phrase) {
|
||||
dueDate = phrase.date;
|
||||
for (let k = i; k < i + phrase.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value };
|
||||
i += phrase.consumed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Rebuild the title from the remaining tokens, in order: plain words, plus
|
||||
// any project/tag tokens that did not resolve to a known name (their original
|
||||
// # / @ prefix is restored so nothing is silently dropped).
|
||||
const title = tokens
|
||||
.filter((t) => {
|
||||
if (t.kind === "word") return true;
|
||||
if (t.kind === "project") return !resolvedProjects.has(t.value);
|
||||
if (t.kind === "tag") return !resolvedTags.has(t.value);
|
||||
return false;
|
||||
})
|
||||
.map((t) => {
|
||||
if (t.kind === "project" && !resolvedProjects.has(t.value)) return `#${t.value}`;
|
||||
if (t.kind === "tag" && !resolvedTags.has(t.value)) return `@${t.value}`;
|
||||
return t.value;
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
dueDate,
|
||||
priority,
|
||||
project,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
recurrence,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export interface ParsedTaskInput {
|
||||
title: string;
|
||||
dueDate: string | null;
|
||||
priority: "low" | "medium" | "high" | "urgent" | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const PRIORITY_MAP: Record<string, ParsedTaskInput["priority"]> = {
|
||||
p1: "urgent",
|
||||
p2: "high",
|
||||
p3: "medium",
|
||||
p4: "low",
|
||||
urgent: "urgent",
|
||||
high: "high",
|
||||
medium: "medium",
|
||||
low: "low",
|
||||
};
|
||||
|
||||
function parseDueDate(input: string): { date: Date | null; matched: string | null } {
|
||||
const lower = input.toLowerCase();
|
||||
const now = new Date();
|
||||
const base = new Date(now);
|
||||
base.setHours(0, 0, 0, 0);
|
||||
|
||||
let matched: string | null = null;
|
||||
let date: Date | null = null;
|
||||
|
||||
const tryTime = (s: string): { hours: number; minutes: number; rest: string } | null => {
|
||||
const m = s.match(/(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
|
||||
if (!m) return null;
|
||||
let h = parseInt(m[1], 10);
|
||||
const min = parseInt(m[2] || "0", 10);
|
||||
const ap = m[3]?.toLowerCase();
|
||||
if (ap === "pm" && h < 12) h += 12;
|
||||
if (ap === "am" && h === 12) h = 0;
|
||||
return { hours: h, minutes: min, rest: m[0] };
|
||||
};
|
||||
|
||||
const applyTime = (d: Date, raw: string) => {
|
||||
const t = tryTime(raw);
|
||||
if (t) {
|
||||
d.setHours(t.hours, t.minutes, 0, 0);
|
||||
if (!matched) matched = raw.trim();
|
||||
else matched += " " + t.rest;
|
||||
} else {
|
||||
d.setHours(9, 0, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (lower.includes("today")) {
|
||||
date = new Date(base);
|
||||
matched = "today";
|
||||
const idx = lower.indexOf("today");
|
||||
applyTime(date, lower.slice(idx + 5, idx + 20));
|
||||
} else if (lower.includes("tomorrow")) {
|
||||
date = new Date(base);
|
||||
date.setDate(date.getDate() + 1);
|
||||
matched = "tomorrow";
|
||||
const idx = lower.indexOf("tomorrow");
|
||||
applyTime(date, lower.slice(idx + 8, idx + 25));
|
||||
} else if (lower.includes("yesterday")) {
|
||||
date = new Date(base);
|
||||
date.setDate(date.getDate() - 1);
|
||||
matched = "yesterday";
|
||||
} else if (/in\s+(\d+)\s+days?/i.test(lower)) {
|
||||
const m = lower.match(/in\s+(\d+)\s+days?/i)!;
|
||||
date = new Date(base);
|
||||
date.setDate(date.getDate() + parseInt(m[1], 10));
|
||||
matched = m[0];
|
||||
} else if (/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) {
|
||||
const m = lower.match(/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!;
|
||||
const days: Record<string, number> = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
|
||||
const target = days[m[1].toLowerCase()];
|
||||
date = new Date(base);
|
||||
const cur = date.getDay();
|
||||
let diff = (target - cur + 7) % 7;
|
||||
if (diff === 0) diff = 7;
|
||||
date.setDate(date.getDate() + diff);
|
||||
matched = m[0];
|
||||
} else if (/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) {
|
||||
const m = lower.match(/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!;
|
||||
const days: Record<string, number> = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
|
||||
const target = days[m[1].toLowerCase()];
|
||||
date = new Date(base);
|
||||
const cur = date.getDay();
|
||||
let diff = (target - cur + 7) % 7;
|
||||
if (diff === 0) diff = 7;
|
||||
date.setDate(date.getDate() + diff);
|
||||
matched = m[0];
|
||||
}
|
||||
|
||||
if (date && !matched?.includes("at") && !matched?.includes(":")) {
|
||||
const t = tryTime(lower);
|
||||
if (t && t.hours !== 9) {
|
||||
date.setHours(t.hours, t.minutes, 0, 0);
|
||||
matched = (matched || "") + " " + t.rest;
|
||||
}
|
||||
}
|
||||
|
||||
return { date, matched: matched?.trim() || null };
|
||||
}
|
||||
|
||||
export function parseTaskInput(raw: string): ParsedTaskInput {
|
||||
let title = raw;
|
||||
|
||||
const tags: string[] = [];
|
||||
const tagRegex = /#(\w[\w-]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tagRegex.exec(raw)) !== null) tags.push(m[1].toLowerCase());
|
||||
title = title.replace(tagRegex, "").trim();
|
||||
|
||||
let priority: ParsedTaskInput["priority"] = null;
|
||||
const prioRegex = /\b(p[1-4]|urgent|high|medium|low)\b/i;
|
||||
const prioMatch = raw.match(prioRegex);
|
||||
if (prioMatch) {
|
||||
const key = prioMatch[1].toLowerCase();
|
||||
priority = PRIORITY_MAP[key] || null;
|
||||
title = title.replace(prioMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const due = parseDueDate(raw);
|
||||
if (due.date) {
|
||||
const esc = due.matched!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
title = title.replace(new RegExp(esc, "i"), "").trim();
|
||||
title = title.replace(/\b(at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/i, "").trim();
|
||||
title = title.replace(/\bat\b$/i, "").trim();
|
||||
}
|
||||
|
||||
title = title.replace(/\s{2,}/g, " ").trim();
|
||||
if (!title) title = raw.replace(tagRegex, "").replace(prioRegex, "").trim() || raw;
|
||||
|
||||
return { title: title || raw, dueDate: due.date ? due.date.toISOString() : null, priority, tags };
|
||||
}
|
||||
@@ -21,40 +21,6 @@ export const TASK_STATUS: Record<string, StatusToken> = {
|
||||
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Category → hex color fallback for custom workflow statuses. Custom statuses
|
||||
* carry their own `color`; when unset the category color applies. Hex (not
|
||||
* Tailwind classes) so it can drive inline `style={{ backgroundColor }}`.
|
||||
*/
|
||||
export const TASK_STATUS_CATEGORY_COLOR: Record<string, string> = {
|
||||
todo: "#94a3b8",
|
||||
in_progress: "#3b82f6",
|
||||
done: "#22c55e",
|
||||
cancelled: "#ef4444",
|
||||
};
|
||||
|
||||
/** Label for a status definition (falls back to the raw category name). */
|
||||
export function getStatusLabel(status: { label?: string; category?: string } | null | undefined): string {
|
||||
if (status?.label) return status.label;
|
||||
if (status?.category) return status.category.replace("_", " ");
|
||||
return "No status";
|
||||
}
|
||||
|
||||
/** Hex color for a status definition: its own color or the category fallback. */
|
||||
export function getStatusColor(status: { color?: string | null; category?: string } | null | undefined): string {
|
||||
if (status?.color) return status.color;
|
||||
return TASK_STATUS_CATEGORY_COLOR[status?.category ?? "todo"] ?? "#94a3b8";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind token for a status definition (used where inline styles are awkward).
|
||||
* Custom colors can't map to Tailwind classes, so this only covers the category
|
||||
* fallback; callers needing exact colors should use `getStatusColor`.
|
||||
*/
|
||||
export function getStatusToken(status: { category?: string } | null | undefined): StatusToken {
|
||||
return TASK_STATUS[status?.category ?? "todo"] ?? TASK_STATUS.todo;
|
||||
}
|
||||
|
||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
||||
|
||||
+11
-127
@@ -1,32 +1,10 @@
|
||||
// Shared types for Project E entities
|
||||
|
||||
export type TaskStatusCategory = "todo" | "in_progress" | "done" | "cancelled";
|
||||
|
||||
/** A per-project workflow status definition. */
|
||||
export interface StatusDefinition {
|
||||
id: string;
|
||||
projectId: string;
|
||||
/** Stable machine key unique per project, e.g. "in_review". */
|
||||
key: string;
|
||||
/** Display label, e.g. "In Review". */
|
||||
label: string;
|
||||
/** UI semantics: drives progress, board columns and completion checks. */
|
||||
category: TaskStatusCategory;
|
||||
/** Hex color, or null to fall back to the category color. */
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
statusId: string | null;
|
||||
/** The resolved status definition (null when unassigned or status deleted). */
|
||||
status: StatusDefinition | null;
|
||||
status: "todo" | "in_progress" | "done" | "cancelled";
|
||||
priority: "low" | "medium" | "high" | "urgent";
|
||||
domainId: string;
|
||||
projectId: string | null;
|
||||
@@ -43,16 +21,8 @@ export interface Task {
|
||||
tags: Tag[];
|
||||
customFields?: Record<string, unknown>;
|
||||
subtasks?: Task[];
|
||||
dependencies?: TaskSummary[];
|
||||
dependents?: TaskSummary[];
|
||||
}
|
||||
|
||||
/** Slim task row used in dependency lists. */
|
||||
export interface TaskSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
statusId: string | null;
|
||||
status: StatusDefinition | null;
|
||||
dependencies?: { id: string; title: string; status: string }[];
|
||||
dependents?: { id: string; title: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface Habit {
|
||||
@@ -103,8 +73,6 @@ export interface Project {
|
||||
taskCount: number;
|
||||
completedCount: number;
|
||||
progress: number;
|
||||
/** This project's workflow status definitions (defaults seeded on create). */
|
||||
statuses?: StatusDefinition[];
|
||||
sections?: Section[];
|
||||
tasks?: Task[];
|
||||
}
|
||||
@@ -121,40 +89,6 @@ export interface Section {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Automation Rules ────────────────────────────────────────────────────────────
|
||||
|
||||
export type AutomationTriggerType = "task_status_changed" | "task_created" | "due_date_approaching";
|
||||
export type AutomationActionType = "set_status" | "set_priority" | "add_label" | "create_notification";
|
||||
export type AutomationConditionField = "project" | "status" | "priority" | "label";
|
||||
|
||||
export interface AutomationTrigger {
|
||||
type: AutomationTriggerType;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutomationCondition {
|
||||
field: AutomationConditionField;
|
||||
op: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
type: AutomationActionType;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationCondition[];
|
||||
actions: AutomationAction[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -336,34 +270,19 @@ export interface Agent {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type NotificationType = "mention" | "status_change" | "due_soon" | "automation" | "assignment" | (string & {});
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string | null;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string | null;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
/** NULL = unread. */
|
||||
readAt: string | null;
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
changes: Record<string, unknown> | null;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationsResponse {
|
||||
items: Notification[];
|
||||
totalItems: number;
|
||||
unreadCount: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface NotificationCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -380,44 +299,7 @@ export interface AgentActivity {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Canvas
|
||||
export interface Canvas {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
mode: "freeform" | "graph";
|
||||
domainId: string;
|
||||
tags: string[];
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
background: string | null;
|
||||
customFields: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
cards?: CanvasCard[];
|
||||
connections?: CanvasConnection[];
|
||||
}
|
||||
|
||||
export interface CanvasCard {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
type: string;
|
||||
content: string;
|
||||
position: { x: number; y: number };
|
||||
size: { w: number; h: number };
|
||||
zIndex: number;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CanvasConnection {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
sourceCardId: string;
|
||||
targetCardId: string;
|
||||
label: string | null;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
// Daily Notes
|
||||
export interface DailyNote {
|
||||
@@ -444,6 +326,8 @@ export interface HabitAnalytics {
|
||||
habitConsistency: number;
|
||||
totalHabits: number;
|
||||
totalLogs: number;
|
||||
totalExpected?: number;
|
||||
perHabit?: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }>;
|
||||
activeStreaks: number;
|
||||
bestStreak: number;
|
||||
period: number;
|
||||
|
||||
@@ -13,13 +13,11 @@ import { Route as searchRoute } from "./routes/_app/search";
|
||||
import { Route as analyticsRoute } from "./routes/_app/analytics";
|
||||
import { Route as agentsRoute } from "./routes/_app/agents";
|
||||
import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
|
||||
import { Route as canvasRoute } from "./routes/_app/canvas";
|
||||
import { Route as dailyRoute } from "./routes/_app/daily";
|
||||
import { Route as settingsRoute } from "./routes/_app/settings";
|
||||
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
|
||||
import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
|
||||
import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
|
||||
import { Route as canvasDetailRoute } from "./routes/_app/canvas/$id";
|
||||
import { Route as projectDetailRoute } from "./routes/_app/projects/$id";
|
||||
|
||||
const appChildren = [
|
||||
@@ -34,13 +32,11 @@ const appChildren = [
|
||||
analyticsRoute,
|
||||
agentsRoute,
|
||||
agentActivityRoute,
|
||||
canvasRoute,
|
||||
dailyRoute,
|
||||
settingsRoute,
|
||||
taskDetailRoute,
|
||||
habitDetailRoute,
|
||||
noteDetailRoute,
|
||||
canvasDetailRoute,
|
||||
projectDetailRoute,
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Sidebar } from "@/components/shell/sidebar";
|
||||
import { Topbar } from "@/components/shell/topbar";
|
||||
import { CommandPalette } from "@/components/shell/command-palette";
|
||||
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
|
||||
import { QuickAddBar } from "@/components/quick-add-bar";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
function AppLayout() {
|
||||
@@ -42,7 +41,6 @@ function AppLayout() {
|
||||
</div>
|
||||
<CommandPalette />
|
||||
<ShortcutsHelp />
|
||||
<QuickAddBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ function AnalyticsPage() {
|
||||
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
|
||||
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
|
||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
|
||||
const { data: velocityData } = useApiQuery<{ items: Array<{date:string;completed:number}>; avg:number }>(["analytics-velocity", activeDomainId, range], "/analytics/velocity?range=" + range + domainSuffix);
|
||||
const { data: cycleData } = useApiQuery<{ median:number; avg:number; count:number }>(["analytics-cycle", activeDomainId, range], "/analytics/cycle?range=" + range + domainSuffix);
|
||||
|
||||
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
|
||||
const analyticsError = habitsError || projectsError || dailyError;
|
||||
@@ -36,9 +38,10 @@ function AnalyticsPage() {
|
||||
const dailyItems = dailyData?.items || [];
|
||||
|
||||
const habitRateData = useMemo(() => {
|
||||
const expected = (habitData as any)?.totalExpected ?? (habitData?.totalHabits || 0) * parseInt(range);
|
||||
return [
|
||||
{ name: "Completed", value: habitData?.totalLogs || 0 },
|
||||
{ name: "Missed", value: Math.max(0, (habitData?.totalHabits || 1) * parseInt(range) - (habitData?.totalLogs || 0)) },
|
||||
{ name: "Missed", value: Math.max(0, expected - (habitData?.totalLogs || 0)) },
|
||||
];
|
||||
}, [habitData, range]);
|
||||
|
||||
@@ -161,6 +164,30 @@ function AnalyticsPage() {
|
||||
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Velocity */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Velocity</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">avg {velocityData?.avg ?? 0}/day</span>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cycle time */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center py-4">
|
||||
<p className="text-2xl font-bold">{cycleData ? `${cycleData.median}d median · ${cycleData.avg}d avg` : "—"}</p>
|
||||
<p className="text-xs text-muted-foreground">{cycleData?.count ?? 0} tasks completed in range</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -185,13 +185,28 @@ function CalendarPage() {
|
||||
|
||||
|
||||
const activeDomainId = useApiDomain();
|
||||
const [showTasks, setShowTasks] = useState(true);
|
||||
const [showHabits, setShowHabits] = useState(true);
|
||||
|
||||
const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]);
|
||||
const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]);
|
||||
|
||||
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||
["calendar-events", activeDomainId],
|
||||
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
["calendar-events", activeDomainId, fromISO, toISO],
|
||||
`/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const events = eventsData?.items || [];
|
||||
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
|
||||
["calendar-unified", activeDomainId, fromISO, toISO],
|
||||
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
const events = useMemo(() => {
|
||||
if (!unifiedData?.items) return eventsData?.items || [];
|
||||
const base = unifiedData.items;
|
||||
if (!showTasks && !showHabits) return base.filter(e => !e.entityType || (e.entityType!=="task" && e.entityType!=="habit"));
|
||||
if (!showTasks) return base.filter(e => e.entityType!=="task");
|
||||
if (!showHabits) return base.filter(e => e.entityType!=="habit");
|
||||
return base;
|
||||
}, [unifiedData, eventsData, showTasks, showHabits]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
||||
@@ -223,11 +238,16 @@ function CalendarPage() {
|
||||
|
||||
const handleEventDrop = useCallback(
|
||||
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||
api.patch(`/calendar/events/${event.id}`, {
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||
}).then(() => {
|
||||
const isTask = event.entityType === "task" && String(event.id).startsWith("task-");
|
||||
const patchUrl = isTask ? `/tasks/${event.entityId}` : `/calendar/events/${event.id}`;
|
||||
const patchData = isTask ? { dueDate: (start instanceof Date ? start : new Date(start as string)).toISOString() } : {
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(),
|
||||
};
|
||||
api.patch(patchUrl, patchData).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-unified"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
@@ -235,11 +255,13 @@ function CalendarPage() {
|
||||
|
||||
const handleEventResize = useCallback(
|
||||
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||
if (String(event.id).startsWith("task-") || String(event.id).startsWith("habit-")) return;
|
||||
api.patch(`/calendar/events/${event.id}`, {
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||
startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(),
|
||||
endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(),
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["calendar-unified"] });
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
@@ -268,7 +290,12 @@ function CalendarPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showTasks} onChange={e=>setShowTasks(e.target.checked)} /> Tasks</label>
|
||||
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showHabits} onChange={e=>setShowHabits(e.target.checked)} /> Habits</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{["month", "week", "day", "agenda"].map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
@@ -280,6 +307,7 @@ function CalendarPage() {
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{eventsLoading && events.length === 0 ? (
|
||||
<LoadingState label="Loading events..." />
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const BLOCK_TYPES = [
|
||||
{ id: "text", label: "Text", icon: Type },
|
||||
{ id: "heading1", label: "Heading 1", icon: Heading1 },
|
||||
{ id: "heading2", label: "Heading 2", icon: Heading2 },
|
||||
{ id: "heading3", label: "Heading 3", icon: Heading2 },
|
||||
{ id: "bullet_list", label: "Bullet List", icon: List },
|
||||
{ id: "todo", label: "Todo", icon: CheckSquare },
|
||||
{ id: "code", label: "Code", icon: Code },
|
||||
{ id: "image", label: "Image", icon: Image },
|
||||
] as const;
|
||||
|
||||
// ─── Block Editor ────────────────────────────────────────────────────────
|
||||
|
||||
function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
|
||||
block: { id: string; type: string; content: string };
|
||||
onChange: (id: string, content: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}) {
|
||||
const [showSlash, setShowSlash] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "/" && block.content === "") {
|
||||
setShowSlash(true);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setShowSlash(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
onChange(block.id, value);
|
||||
if (value.startsWith("/")) {
|
||||
setShowSlash(true);
|
||||
} else {
|
||||
setShowSlash(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertBlockType = (type: string) => {
|
||||
onChange(block.id, "");
|
||||
// We can't change the type directly in this simple model, so we signal via a custom event
|
||||
const event = new CustomEvent("change-block-type", { detail: { id: block.id, type } });
|
||||
window.dispatchEvent(event);
|
||||
setShowSlash(false);
|
||||
};
|
||||
|
||||
const renderEditor = () => {
|
||||
switch (block.type) {
|
||||
case "heading1":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 1..."
|
||||
className="w-full text-2xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "heading2":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 2..."
|
||||
className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "heading3":
|
||||
return (
|
||||
<input
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Heading 3..."
|
||||
className="w-full text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "todo":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded border-muted-foreground/30" />
|
||||
<input
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Todo item..."
|
||||
className="flex-1 bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<textarea
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Code..."
|
||||
rows={4}
|
||||
className="w-full font-mono text-sm bg-muted p-3 rounded border-none outline-none resize-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Image URL..."
|
||||
className="w-full bg-transparent border-b border-muted-foreground/20 outline-none text-sm placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
{block.content && (
|
||||
<img src={block.content} alt="" className="max-w-full h-auto rounded-lg" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef as any}
|
||||
value={block.content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type / for commands..."
|
||||
rows={2}
|
||||
className="w-full bg-transparent border-none outline-none resize-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pt-1 shrink-0">
|
||||
<button onClick={onMoveUp} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowUp className="h-3 w-3" /></button>
|
||||
<button onClick={onMoveDown} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowDown className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
<button onClick={() => onDelete(block.id)} className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0 pt-1">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{showSlash && (
|
||||
<div className="absolute left-8 top-full z-50 mt-1 bg-popover border rounded-lg shadow-lg p-1 w-48">
|
||||
{BLOCK_TYPES.map((bt) => {
|
||||
const Icon = bt.icon;
|
||||
return (
|
||||
<button key={bt.id} onClick={() => insertBlockType(bt.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent transition-colors">
|
||||
<Icon className="h-4 w-4" />
|
||||
{bt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Canvas Editor ────────────────────────────────────────────────────────
|
||||
|
||||
type Block = { id: string; type: string; content: string };
|
||||
|
||||
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [blocks, setBlocks] = useState<Block[]>(
|
||||
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
|
||||
);
|
||||
const [title, setTitle] = useState(canvas.name);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const blockIdCounter = useRef(blocks.length + 1);
|
||||
const lastSavedTitle = useRef(canvas.name);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isDirty = useRef(false);
|
||||
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
|
||||
|
||||
// Keep latest values reachable from the debounced save without stale closures
|
||||
const blocksRef = useRef(blocks);
|
||||
blocksRef.current = blocks;
|
||||
const titleRef = useRef(title);
|
||||
titleRef.current = title;
|
||||
|
||||
// Listen for block type changes
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setBlocks((prev) => prev.map((b) => b.id === detail.id ? { ...b, type: detail.type } : b));
|
||||
};
|
||||
window.addEventListener("change-block-type", handler);
|
||||
return () => window.removeEventListener("change-block-type", handler);
|
||||
}, []);
|
||||
|
||||
// Persist the full block list via the bulk endpoint, then adopt the
|
||||
// server-generated ids for newly created blocks (content stays local).
|
||||
const persist = useCallback(async () => {
|
||||
const currentBlocks = blocksRef.current;
|
||||
const currentTitle = titleRef.current;
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const payload = {
|
||||
cards: currentBlocks.map((b, i) => ({
|
||||
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
|
||||
type: b.type,
|
||||
content: b.content,
|
||||
zIndex: i,
|
||||
})),
|
||||
};
|
||||
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
|
||||
setBlocks((prev) => {
|
||||
if (result.cards.length !== prev.length) return prev;
|
||||
let changed = false;
|
||||
const next = prev.map((b, i) => {
|
||||
const newId = result.cards[i]?.id;
|
||||
if (newId && b.id !== newId) {
|
||||
changed = true;
|
||||
return { ...b, id: newId };
|
||||
}
|
||||
return b;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
if (currentTitle !== lastSavedTitle.current) {
|
||||
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
|
||||
lastSavedTitle.current = currentTitle;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
isDirty.current = false;
|
||||
setSaveState("saved");
|
||||
} catch (error) {
|
||||
console.error("[canvas] save failed:", error);
|
||||
setSaveState("error");
|
||||
}
|
||||
}, [canvas.id, queryClient]);
|
||||
|
||||
// Debounced autosave: persist shortly after blocks/title stop changing
|
||||
useEffect(() => {
|
||||
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
|
||||
return;
|
||||
}
|
||||
lastSeen.current = { blocks, title };
|
||||
isDirty.current = true;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
persist();
|
||||
}, 800);
|
||||
return () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [blocks, title, persist]);
|
||||
|
||||
// Flush any unsaved edits when leaving the editor
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isDirty.current) {
|
||||
persist();
|
||||
}
|
||||
};
|
||||
}, [persist]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
persist();
|
||||
};
|
||||
|
||||
const handleBlockChange = (id: string, content: string) => {
|
||||
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
};
|
||||
|
||||
const handleMoveUp = (idx: number) => {
|
||||
if (idx === 0) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveDown = (idx: number) => {
|
||||
if (idx >= blocks.length - 1) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addBlock = (type = "text") => {
|
||||
blockIdCounter.current++;
|
||||
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>← Back</Button>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
|
||||
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
|
||||
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{blocks.map((block, idx) => (
|
||||
<BlockEditor
|
||||
key={block.id}
|
||||
block={block}
|
||||
onChange={handleBlockChange}
|
||||
onDelete={handleDelete}
|
||||
onMoveUp={() => handleMoveUp(idx)}
|
||||
onMoveDown={() => handleMoveDown(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{BLOCK_TYPES.slice(0, 4).map((bt) => {
|
||||
const Icon = bt.icon;
|
||||
return (
|
||||
<Button key={bt.id} variant="outline" size="sm" onClick={() => addBlock(bt.id)}>
|
||||
<Icon className="h-4 w-4 mr-1" />{bt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button variant="ghost" size="sm" onClick={() => addBlock("text")}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Block
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Canvas List ──────────────────────────────────────────────────────────
|
||||
|
||||
function CanvasList() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const activeDomainId = useApiDomain();
|
||||
|
||||
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
||||
const canvases = data?.items || [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }),
|
||||
onSuccess: (canvas) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
navigate({ to: "/canvas/$id", params: { id: canvas.id } });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/canvas/" + id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Canvas</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="h-4 w-4 mr-2" />New Canvas</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Canvas</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="canvas-name">Name</Label>
|
||||
<Input id="canvas-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Canvas name" />
|
||||
</div>
|
||||
<Button onClick={() => createMutation.mutate(newName)} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading canvases...</div>
|
||||
) : canvases.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No canvases yet. Create your first one!</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{canvases.map((c) => (
|
||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow group" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={"Delete canvas " + c.name}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">{c.mode}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{c.cards?.length || 0} blocks</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/canvas",
|
||||
component: CanvasList,
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../../_app";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Calendar, Clock, FileText, LayoutDashboard, Trash2 } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
|
||||
import { InlineTextarea } from "@/components/entities/inline-edit";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
import type { Canvas } from "@/lib/types";
|
||||
import { CanvasEditor } from "../canvas";
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : "Something went wrong";
|
||||
}
|
||||
|
||||
function formatCustomFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function CanvasDetail() {
|
||||
const { id } = useParams({ from: Route.id });
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: canvas, isLoading, isError, error, refetch } = useApiQuery<Canvas>(
|
||||
["canvas", id],
|
||||
"/canvas/" + id
|
||||
);
|
||||
|
||||
const { patch } = useOptimisticPatch<Canvas>({
|
||||
entityKey: ["canvas", id],
|
||||
listKeys: [["canvas"]],
|
||||
patchUrl: (cid) => `/canvas/${cid}`,
|
||||
applyPatch: (current, data) => ({ ...current, ...data }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/canvas/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||
toast.success("Canvas deleted");
|
||||
navigate({ to: "/canvas" });
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading canvas..." />;
|
||||
if (isError) {
|
||||
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||
}
|
||||
if (!canvas) return <ErrorState message="Canvas not found" />;
|
||||
|
||||
const customFieldEntries = Object.entries(canvas.customFields ?? {});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />
|
||||
</div>
|
||||
<aside className="w-full shrink-0 space-y-6 lg:w-72">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||
<InlineTextarea
|
||||
value={canvas.description ?? ""}
|
||||
onSave={(description) =>
|
||||
patch({ id, data: { description: description || null } })
|
||||
}
|
||||
placeholder="Add a description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Details</p>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Badge variant="outline">{canvas.mode}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>{canvas.cards?.length ?? 0} blocks</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>Created {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>Updated {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Tags</p>
|
||||
{canvas.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{canvas.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No tags</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customFieldEntries.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Custom fields</p>
|
||||
<dl className="space-y-2">
|
||||
{customFieldEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<dt className="shrink-0 text-muted-foreground">{key}</dt>
|
||||
<dd className="truncate text-right">{formatCustomFieldValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4" /> Delete Canvas
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{canvas.name}"? All blocks in it will be
|
||||
removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "canvas/$id",
|
||||
component: CanvasDetail,
|
||||
});
|
||||
@@ -86,13 +86,22 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Note Editor ────────────────────────────────────────────────────
|
||||
// ─── Daily Note Editor ────────────────────────────────────────────────────
|
||||
|
||||
function DailyNoteEditor({ date }: { date: Date }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const { data: tasksData } = useApiQuery<{ items: Array<{ id:string; title:string; status:string; dueDate:string|null }> }>(["tasks-daily", dateStr, activeDomainId], "/tasks?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
||||
const { data: habitsData } = useApiQuery<{ items: Array<{ id:string; name:string }> }>(["habits-daily", activeDomainId], "/habits?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
||||
const tasksDue = (tasksData?.items || []).filter(t => t.dueDate && t.dueDate.slice(0,10) === dateStr && t.status !== "done");
|
||||
const habitsToday = habitsData?.items || [];
|
||||
const insertTemplate = () => {
|
||||
const tpl = `# ${format(date, "EEEE, MMM d")}\n\n## Tasks Due Today\n${tasksDue.length ? tasksDue.map(t => `- [ ] ${t.title}`).join("\n") : "- No tasks due"}\n\n## Habits\n${habitsToday.slice(0,5).map(h => `- [ ] ${h.name}`).join("\n") || "- No habits"}\n\n## Notes\n`;
|
||||
setContent(tpl);
|
||||
autoSave(tpl, mood, energy);
|
||||
};
|
||||
const [content, setContent] = useState("");
|
||||
const [mood, setMood] = useState<number | null>(null);
|
||||
const [energy, setEnergy] = useState<number | null>(null);
|
||||
@@ -319,11 +328,15 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
|
||||
) : isNew && !content ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mb-4">No note for this day — click to start writing</p>
|
||||
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
||||
<Plus className="h-4 w-4 mr-2" />Start Writing
|
||||
</Button>
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<p className="text-muted-foreground">No note for this day</p>
|
||||
{tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
||||
<Plus className="h-4 w-4 mr-2" />Start Writing
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={insertTemplate}>Insert Daily Template</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
|
||||
@@ -37,7 +37,7 @@ function TasksDueWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
|
||||
const tasks = data?.items || [];
|
||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status?.category !== "done");
|
||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{today.length === 0 && overdue.length === 0 ? (
|
||||
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
FolderKanban,
|
||||
ListTodo,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
} from "@/components/entities/inline-edit";
|
||||
import { EntityActivity } from "@/components/entities/entity-activity";
|
||||
import { EntityComments } from "@/components/entities/entity-comments";
|
||||
import { AutomationRuleBuilder, TRIGGER_OPTIONS, summarizeActions } from "@/components/automation-rule-builder";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -55,12 +52,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
import { GanttChart } from "@/components/gantt/gantt-chart";
|
||||
import type { TimelineData } from "@/components/gantt/gantt-utils";
|
||||
import { getStatusToken, PRIORITY, PROJECT_STATUS } from "@/lib/status-colors";
|
||||
import type { AutomationRule, Project, Section, Task } from "@/lib/types";
|
||||
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
||||
import type { Project, Section, Task } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
|
||||
@@ -232,16 +226,6 @@ function ProjectDetail() {
|
||||
},
|
||||
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
|
||||
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
|
||||
{
|
||||
value: "timeline",
|
||||
label: "Timeline",
|
||||
content: <ProjectTimeline project={project} />,
|
||||
},
|
||||
{
|
||||
value: "automations",
|
||||
label: "Automations",
|
||||
content: <ProjectAutomations project={project} />,
|
||||
},
|
||||
{
|
||||
value: "activity",
|
||||
label: "Activity",
|
||||
@@ -358,8 +342,8 @@ function ProjectTasks({ project }: { project: Project }) {
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ taskId, statusId }: { taskId: string; statusId: string | null }) =>
|
||||
api.post<Task>(`/tasks/${taskId}/status`, { statusId }),
|
||||
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
|
||||
api.post<Task>(`/tasks/${taskId}/status`, { status }),
|
||||
onMutate: (vars) => setPendingId(vars.taskId),
|
||||
onSettled: () => setPendingId(null),
|
||||
onSuccess: refresh,
|
||||
@@ -454,7 +438,6 @@ function ProjectTasks({ project }: { project: Project }) {
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
project={project}
|
||||
pending={pendingId === task.id}
|
||||
onToggle={(vars) => toggleMutation.mutate(vars)}
|
||||
onOpen={() => openTask(task.id)}
|
||||
@@ -477,7 +460,6 @@ function ProjectTasks({ project }: { project: Project }) {
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
project={project}
|
||||
pending={pendingId === task.id}
|
||||
onToggle={(vars) => toggleMutation.mutate(vars)}
|
||||
onOpen={() => openTask(task.id)}
|
||||
@@ -493,43 +475,37 @@ function ProjectTasks({ project }: { project: Project }) {
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
project,
|
||||
pending,
|
||||
onToggle,
|
||||
onOpen,
|
||||
}: {
|
||||
task: Task;
|
||||
project: Project;
|
||||
pending: boolean;
|
||||
onToggle: (vars: { taskId: string; statusId: string | null }) => void;
|
||||
onToggle: (vars: { taskId: string; status: Task["status"] }) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const isDone = task.status?.category === "done";
|
||||
const statuses = project.statuses ?? [];
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={isDone}
|
||||
checked={task.status === "done"}
|
||||
disabled={pending}
|
||||
onCheckedChange={() =>
|
||||
onToggle({
|
||||
taskId: task.id,
|
||||
statusId: isDone
|
||||
? (statuses.find((s) => s.category === "todo")?.id ?? null)
|
||||
: (statuses.find((s) => s.category === "done")?.id ?? null),
|
||||
status: task.status === "done" ? "todo" : "done",
|
||||
})
|
||||
}
|
||||
aria-label={
|
||||
"Mark " + task.title + " " + (isDone ? "as not done" : "as done")
|
||||
"Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done")
|
||||
}
|
||||
/>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(task.status).dot)} />
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[task.status]?.dot)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||
isDone && "text-muted-foreground line-through"
|
||||
task.status === "done" && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
@@ -714,156 +690,6 @@ function Sections({ project }: { project: Project }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectAutomations({ project }: { project: Project }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [builderOpen, setBuilderOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<AutomationRule | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useApiQuery<{
|
||||
items: AutomationRule[];
|
||||
}>(["automations", project.id], `/projects/${project.id}/automations`);
|
||||
|
||||
const rules = data?.items ?? [];
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["automations", project.id] });
|
||||
};
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (ruleId: string) =>
|
||||
api.post(`/projects/${project.id}/automations/${ruleId}/toggle`),
|
||||
onSuccess: refresh,
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (ruleId: string) =>
|
||||
api.delete(`/projects/${project.id}/automations/${ruleId}`),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule deleted");
|
||||
refresh();
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRule(null);
|
||||
setBuilderOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: AutomationRule) => {
|
||||
setEditingRule(rule);
|
||||
setBuilderOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading automations..." />;
|
||||
if (isError) return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically run actions when tasks change in this project.
|
||||
</p>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" /> Create Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No automation rules yet — e.g. add a "shipped" label when a task is done.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => {
|
||||
const triggerLabel =
|
||||
TRIGGER_OPTIONS.find((t) => t.value === rule.trigger.type)?.label ??
|
||||
rule.trigger.type;
|
||||
const actionSummaries = summarizeActions(project, rule.actions);
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{rule.name}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
When {triggerLabel.toLowerCase()}
|
||||
{rule.conditions.length > 0
|
||||
? ` (${rule.conditions.length} ${rule.conditions.length === 1 ? "condition" : "conditions"})`
|
||||
: ""}{" "}
|
||||
→ {actionSummaries.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule.active}
|
||||
onCheckedChange={() => toggleMutation.mutate(rule.id)}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={"Toggle " + rule.name}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(rule)}
|
||||
aria-label={"Edit " + rule.name}
|
||||
title="Edit rule"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteMutation.mutate(rule.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={"Delete " + rule.name}
|
||||
title="Delete rule"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{builderOpen ? (
|
||||
<AutomationRuleBuilder
|
||||
project={project}
|
||||
open={builderOpen}
|
||||
onOpenChange={setBuilderOpen}
|
||||
rule={editingRule}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTimeline({ project }: { project: Project }) {
|
||||
const { data, isLoading, isError, error, refetch } = useApiQuery<TimelineData>(
|
||||
["timeline", project.domainId, project.id],
|
||||
`/domains/${project.domainId}/projects/${project.id}/timeline`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading timeline..." />;
|
||||
if (isError) return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||
if (!data) return <ErrorState message="Timeline data unavailable" />;
|
||||
|
||||
return (
|
||||
<GanttChart
|
||||
domainId={project.domainId}
|
||||
projectId={project.id}
|
||||
tasks={data.tasks}
|
||||
milestones={data.milestones}
|
||||
statuses={project.statuses ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "projects/$id",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { Search as SearchIcon, X, Clock, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { SearchResult } from "@/lib/types";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
{ id: "task", label: "Tasks", color: "bg-blue-500" },
|
||||
@@ -62,7 +60,25 @@ function SearchPage() {
|
||||
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
|
||||
const results = searchData?.results || [];
|
||||
const serverResults = searchData?.results || [];
|
||||
|
||||
const results = useMemo(() => {
|
||||
const rawQuery = debouncedQuery.trim();
|
||||
const operatorMatch = rawQuery.match(/\b(in|status|tag):(\S+)/g);
|
||||
let filtered = serverResults;
|
||||
if (operatorMatch) {
|
||||
for (const op of operatorMatch) {
|
||||
const [key, val] = op.split(":");
|
||||
if (key === "in") filtered = filtered.filter(r => r.type === val.toLowerCase());
|
||||
if (key === "status") filtered = filtered.filter(r => r.snippet.toLowerCase().includes(val.toLowerCase()) || r.title.toLowerCase().includes(val.toLowerCase()));
|
||||
}
|
||||
}
|
||||
const cleanQuery = rawQuery.replace(/\b(in|status|tag):\S+\s*/g, "").trim();
|
||||
if (!cleanQuery) return filtered;
|
||||
const fuse = new Fuse(filtered, { keys: ["title", "snippet"], threshold: 0.4, includeScore: true });
|
||||
const fused = fuse.search(cleanQuery);
|
||||
return fused.length > 0 ? fused.map(f => f.item) : filtered;
|
||||
}, [serverResults, debouncedQuery]);
|
||||
|
||||
const toggleType = (typeId: string) => {
|
||||
const next = new Set(selectedTypes);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -27,9 +27,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||
import { getStatusLabel, getStatusToken, TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||
import type { StatusDefinition, Task, TaskStatusCategory, PaginatedResponse } from "@/lib/types";
|
||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||
import type { Task, PaginatedResponse } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseTaskInput } from "@/lib/nlp";
|
||||
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
||||
|
||||
const STATUS_COLUMNS = [
|
||||
{ id: "todo", label: "Todo" },
|
||||
@@ -99,27 +101,17 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
|
||||
);
|
||||
}
|
||||
|
||||
const NO_STATUS = "__none__";
|
||||
|
||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [title, setTitle] = useState(task?.title || "");
|
||||
const [description, setDescription] = useState(task?.description || "");
|
||||
const [statusId, setStatusId] = useState(task?.statusId ?? "");
|
||||
const [status, setStatus] = useState(task?.status || "todo");
|
||||
const [priority, setPriority] = useState(task?.priority || "medium");
|
||||
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
||||
const [recurrenceRule, setRecurrenceRule] = useState(task?.recurrenceRule || "");
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
|
||||
|
||||
// Status is a per-project status definition, so it can only be picked when the
|
||||
// task belongs to a project whose statuses we can load.
|
||||
const projectId = task?.projectId ?? null;
|
||||
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||
["project-statuses", projectId ?? "none"],
|
||||
projectId ? `/projects/${projectId}/statuses` : "",
|
||||
{ enabled: !!projectId }
|
||||
);
|
||||
const statusOptions = statusesData?.items ?? [];
|
||||
const parsed = !task ? parseTaskInput(title) : null;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
||||
@@ -140,9 +132,20 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const data: any = { title: title.trim(), description: description || null, priority };
|
||||
if (statusId) data.statusId = statusId;
|
||||
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
||||
let finalTitle = title.trim();
|
||||
let finalDueDate = dueDate ? new Date(dueDate).toISOString() : null;
|
||||
let finalPriority = priority;
|
||||
let tagNames: string[] = [];
|
||||
if (!task) {
|
||||
const p = parseTaskInput(title);
|
||||
finalTitle = p.title;
|
||||
if (p.dueDate && !dueDate) finalDueDate = p.dueDate;
|
||||
if (p.priority) finalPriority = p.priority;
|
||||
tagNames = p.tags;
|
||||
}
|
||||
const data: any = { title: finalTitle, description: description || null, status, priority: finalPriority, tagNames };
|
||||
if (finalDueDate) data.dueDate = finalDueDate;
|
||||
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
|
||||
const customFields = { ...customFieldValues };
|
||||
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||
if (task) {
|
||||
@@ -155,31 +158,33 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Task title" required />
|
||||
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
||||
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{parsed.dueDate && <Badge variant="outline" className="text-[10px]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
|
||||
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
|
||||
{parsed.tags.map(t => <Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{statusOptions.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={statusId || NO_STATUS}
|
||||
onValueChange={(v) => setStatusId(v === NO_STATUS ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_STATUS}>No status</SelectItem>
|
||||
{statusOptions.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">Todo</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
|
||||
@@ -197,6 +202,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
<RecurrencePicker value={recurrenceRule || null} onChange={(v) => setRecurrenceRule(v || "")} />
|
||||
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
@@ -260,8 +266,8 @@ function TasksPage() {
|
||||
};
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, statusId }: { id: string; statusId: string }) =>
|
||||
api.post("/tasks/" + id + "/status", { statusId }),
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
api.post("/tasks/" + id + "/status", { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
@@ -294,29 +300,11 @@ function TasksPage() {
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
// Status definitions are per-project; cache them so a board drop can resolve
|
||||
// the target category to a real statusId without refetching every time.
|
||||
const statusCache = useRef(new Map<string, StatusDefinition[]>());
|
||||
|
||||
const resolveProjectStatus = useCallback(
|
||||
async (projectId: string | null, category: TaskStatusCategory): Promise<StatusDefinition | null> => {
|
||||
if (!projectId) return null;
|
||||
let statuses = statusCache.current.get(projectId);
|
||||
if (!statuses) {
|
||||
const res = await api.get<{ items: StatusDefinition[] }>(`/projects/${projectId}/statuses`);
|
||||
statuses = res.items ?? [];
|
||||
statusCache.current.set(projectId, statuses);
|
||||
}
|
||||
return statuses.find((s) => s.category === category) ?? null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
@@ -330,7 +318,7 @@ function TasksPage() {
|
||||
// Tasks of a column in persisted order
|
||||
const columnTasks = (status: string) =>
|
||||
tasks
|
||||
.filter((t) => t.status?.category === status)
|
||||
.filter((t) => t.status === status)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// Decide the target column and insertion index:
|
||||
@@ -344,7 +332,7 @@ function TasksPage() {
|
||||
} else {
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (!overTask) return;
|
||||
targetColumn = overTask.status?.category ?? "todo";
|
||||
targetColumn = overTask.status;
|
||||
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||
}
|
||||
@@ -366,24 +354,16 @@ function TasksPage() {
|
||||
currentIds.every((id, i) => id === targetIds[i]);
|
||||
if (unchanged) return;
|
||||
|
||||
const statusChanged = draggedTask.status?.category !== targetColumn;
|
||||
|
||||
// Resolve the target status definition for the dragged task's project so
|
||||
// the optimistic update and the API call carry a real statusId.
|
||||
let targetStatus: StatusDefinition | null = null;
|
||||
if (statusChanged) {
|
||||
targetStatus = await resolveProjectStatus(draggedTask.projectId, targetColumn as TaskStatusCategory);
|
||||
}
|
||||
|
||||
// Optimistic local update so the board reorders immediately
|
||||
const statusChanged = draggedTask.status !== targetColumn;
|
||||
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((t) => {
|
||||
if (t.id === taskId && statusChanged && targetStatus) {
|
||||
return { ...t, status: targetStatus, statusId: targetStatus.id, order: orderById.get(t.id) ?? t.order };
|
||||
if (t.id === taskId && statusChanged) {
|
||||
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
|
||||
}
|
||||
const order = orderById.get(t.id);
|
||||
return order !== undefined ? { ...t, order } : t;
|
||||
@@ -391,8 +371,8 @@ function TasksPage() {
|
||||
};
|
||||
});
|
||||
|
||||
if (statusChanged && targetStatus) {
|
||||
statusMutation.mutate({ id: taskId, statusId: targetStatus.id });
|
||||
if (statusChanged) {
|
||||
statusMutation.mutate({ id: taskId, status: targetColumn });
|
||||
}
|
||||
reorderMutation.mutate({ orderedIds: targetIds });
|
||||
};
|
||||
@@ -411,7 +391,7 @@ function TasksPage() {
|
||||
...col,
|
||||
color: TASK_STATUS[col.id].dot,
|
||||
tasks: tasks
|
||||
.filter((t) => t.status?.category === col.id)
|
||||
.filter((t) => t.status === col.id)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
}));
|
||||
}, [tasks]);
|
||||
@@ -514,7 +494,7 @@ function TasksPage() {
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||
<TableCell className="font-medium">{task.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={cn("text-[10px]", getStatusToken(task.status).badge)}>{getStatusLabel(task.status)}</Badge>
|
||||
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||
|
||||
@@ -55,10 +55,17 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
import { getStatusLabel, getStatusToken, PRIORITY } from "@/lib/status-colors";
|
||||
import type { PaginatedResponse, Project, StatusDefinition, Task } from "@/lib/types";
|
||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||
import type { PaginatedResponse, Project, Task } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_OPTIONS: InlineSelectOption[] = [
|
||||
{ value: "todo", label: "Todo" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "done", label: "Done" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: InlineSelectOption[] = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
@@ -105,18 +112,6 @@ function TaskDetail() {
|
||||
"/tasks/" + id
|
||||
);
|
||||
|
||||
// Load this task's project status definitions so we can resolve a real
|
||||
// statusId when toggling completion (done ↔ todo) and when rendering the
|
||||
// status inline-select.
|
||||
const projectId = task?.projectId ?? null;
|
||||
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||
["project-statuses", projectId ?? "none"],
|
||||
projectId ? `/projects/${projectId}/statuses` : "",
|
||||
{ enabled: !!projectId }
|
||||
);
|
||||
const projectStatuses = statusesData?.items ?? [];
|
||||
const statusById = new Map(projectStatuses.map((s) => [s.id, s]));
|
||||
|
||||
const { patch } = useOptimisticPatch<Task>({
|
||||
entityKey: ["task", id],
|
||||
listKeys: LIST_KEYS,
|
||||
@@ -125,13 +120,10 @@ function TaskDetail() {
|
||||
});
|
||||
|
||||
const toggleComplete = useMutation({
|
||||
mutationFn: () => {
|
||||
const isDone = task?.status?.category === "done";
|
||||
const target = projectStatuses.find((s) => s.category === (isDone ? "todo" : "done"));
|
||||
return api.post<Task>(`/tasks/${id}/status`, {
|
||||
statusId: target?.id ?? "",
|
||||
});
|
||||
},
|
||||
mutationFn: () =>
|
||||
api.post<Task>(`/tasks/${id}/status`, {
|
||||
status: task?.status === "done" ? "todo" : "done",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task", id] });
|
||||
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
|
||||
@@ -160,7 +152,7 @@ function TaskDetail() {
|
||||
}
|
||||
if (!task) return <ErrorState message="Task not found" />;
|
||||
|
||||
const isDone = task.status?.category === "done";
|
||||
const isDone = task.status === "done";
|
||||
|
||||
return (
|
||||
<EntityDetailPage
|
||||
@@ -176,17 +168,14 @@ function TaskDetail() {
|
||||
badges={
|
||||
<>
|
||||
<InlineSelect
|
||||
value={task.status?.id ?? ""}
|
||||
options={projectStatuses.map((s) => ({ value: s.id, label: s.label }))}
|
||||
displayValue={(v) => {
|
||||
const status = statusById.get(v);
|
||||
return (
|
||||
<Badge className={getStatusToken(status).badge}>
|
||||
{status ? status.label : "No status"}
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
onSave={(statusId) => patch({ id, data: { statusId: statusId || null } })}
|
||||
value={task.status}
|
||||
options={STATUS_OPTIONS}
|
||||
displayValue={(v) => (
|
||||
<Badge className={TASK_STATUS[v]?.badge}>
|
||||
{TASK_STATUS[v]?.label ?? v}
|
||||
</Badge>
|
||||
)}
|
||||
onSave={(status) => patch({ id, data: { status } })}
|
||||
/>
|
||||
<InlineSelect
|
||||
value={task.priority}
|
||||
@@ -375,16 +364,6 @@ function Subtasks({ task }: { task: Task }) {
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
// Subtasks live in the same project as the parent, so their status
|
||||
// definitions are the parent's project statuses.
|
||||
const projectId = task.projectId ?? null;
|
||||
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||
["project-statuses", projectId ?? "none"],
|
||||
projectId ? `/projects/${projectId}/statuses` : "",
|
||||
{ enabled: !!projectId }
|
||||
);
|
||||
const projectStatuses = statusesData?.items ?? [];
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
@@ -402,8 +381,8 @@ function Subtasks({ task }: { task: Task }) {
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ subId, statusId }: { subId: string; statusId: string }) =>
|
||||
api.post<Task>(`/tasks/${subId}/status`, { statusId }),
|
||||
mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) =>
|
||||
api.post<Task>(`/tasks/${subId}/status`, { status }),
|
||||
onMutate: (vars) => setPendingId(vars.subId),
|
||||
onSettled: () => setPendingId(null),
|
||||
onSuccess: refresh,
|
||||
@@ -442,37 +421,35 @@ function Subtasks({ task }: { task: Task }) {
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No subtasks yet.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{subtasks.map((sub) => {
|
||||
const subDone = sub.status?.category === "done";
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
{subtasks.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={sub.status === "done"}
|
||||
disabled={pendingId === sub.id}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({
|
||||
subId: sub.id,
|
||||
status: sub.status === "done" ? "todo" : "done",
|
||||
})
|
||||
}
|
||||
aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as done")}
|
||||
/>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[sub.status]?.dot)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||
sub.status === "done" && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={subDone}
|
||||
disabled={pendingId === sub.id}
|
||||
onCheckedChange={() => {
|
||||
const target = projectStatuses.find((s) => s.category === (subDone ? "todo" : "done"));
|
||||
if (!target) return;
|
||||
toggleMutation.mutate({ subId: sub.id, statusId: target.id });
|
||||
}}
|
||||
aria-label={"Mark " + sub.title + " " + (subDone ? "as not done" : "as done")}
|
||||
/>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(sub.status).dot)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||
subDone && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{sub.title}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sub.title}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -546,7 +523,7 @@ function Dependencies({ task }: { task: Task }) {
|
||||
key={dep.id}
|
||||
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(dep.status).dot)} />
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
||||
@@ -554,8 +531,8 @@ function Dependencies({ task }: { task: Task }) {
|
||||
>
|
||||
{dep.title}
|
||||
</button>
|
||||
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}>
|
||||
{getStatusLabel(dep.status)}
|
||||
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
|
||||
{TASK_STATUS[dep.status]?.label ?? dep.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -605,7 +582,7 @@ function Dependencies({ task }: { task: Task }) {
|
||||
key={dep.id}
|
||||
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(dep.status).dot)} />
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
||||
@@ -613,8 +590,8 @@ function Dependencies({ task }: { task: Task }) {
|
||||
>
|
||||
{dep.title}
|
||||
</button>
|
||||
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}>
|
||||
{getStatusLabel(dep.status)}
|
||||
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
|
||||
{TASK_STATUS[dep.status]?.label ?? dep.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -19,6 +19,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user