359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import { useState } from "react";
|
|||
|
|
import {
|
||
|
|
createWebStudioComponent,
|
||
|
|
tryConform,
|
||
|
|
useLiveField,
|
||
|
|
useQuery,
|
||
|
|
type ObjectRef,
|
||
|
|
type QueryState,
|
||
|
|
type ReactComponentImplementationProps,
|
||
|
|
} from "@quixos/web-studio-react-runtime";
|
||
|
|
import {
|
||
|
|
queries,
|
||
|
|
reactInterfaces,
|
||
|
|
type QueryResults,
|
||
|
|
type QueryVariables,
|
||
|
|
type ReactResults,
|
||
|
|
} from "./gen/react-props.gen.js";
|
||
|
|
import "./tracker.css";
|
||
|
|
|
||
|
|
type Tag = QueryResults["Catalog"]["root"]["tags"]["entries"][number]["node"];
|
||
|
|
type TagChoice = { id: string; name: string; color: string };
|
||
|
|
const TaskEditor = createWebStudioComponent<ObjectRef<"atom:task">, { tagChoices: TagChoice[] }, never>({
|
||
|
|
expectedAtomId: "atom:task",
|
||
|
|
});
|
||
|
|
type Props = ReactComponentImplementationProps<ReactResults["propsGet"], { compact?: boolean }, never>;
|
||
|
|
type ListProps = {
|
||
|
|
root: string;
|
||
|
|
variables: QueryVariables["Tasks"];
|
||
|
|
selected: string | null;
|
||
|
|
select(id: string): void;
|
||
|
|
next(cursor: string | null): void;
|
||
|
|
reset(): void;
|
||
|
|
};
|
||
|
|
function Rows({ result, selected, select, next, reset }: ListProps & { result: QueryState<QueryResults["Tasks"]> }) {
|
||
|
|
if (result.status === "error")
|
||
|
|
return (
|
||
|
|
<div role="alert" className="tt-notice">
|
||
|
|
{result.error.message}
|
||
|
|
<button onClick={reset}>Return to first page</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
if (result.status !== "ready") return <p className="tt-notice">Loading tasks…</p>;
|
||
|
|
const page = result.data.root.tasks;
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className="tt-rows" aria-label="Tasks">
|
||
|
|
{page.entries.length === 0 && <p className="tt-notice">No tasks match this view.</p>}
|
||
|
|
{page.entries.map(({ key, node: task }) => (
|
||
|
|
<button
|
||
|
|
className={"tt-row" + (selected === task._qx.ref.$quixosRef ? " is-selected" : "")}
|
||
|
|
aria-pressed={selected === task._qx.ref.$quixosRef}
|
||
|
|
key={key}
|
||
|
|
onClick={() => select(task._qx.ref.$quixosRef)}
|
||
|
|
>
|
||
|
|
<span className={"tt-status status-" + task.status.toLowerCase()}>
|
||
|
|
{task.status === "DONE" ? "✓" : task.status === "ARCHIVED" ? "—" : "○"}
|
||
|
|
</span>
|
||
|
|
<span className="tt-row-body">
|
||
|
|
<strong>{task.title}</strong>
|
||
|
|
<span className="tt-row-meta">
|
||
|
|
{task.dueDate || "No due date"}
|
||
|
|
{task.repeatDays > 0 ? ` · Every ${task.repeatDays}d` : ""}
|
||
|
|
{task.estimatedHours > 0 ? ` · ${task.estimatedHours}h` : ""}
|
||
|
|
{task.priority === "HIGH" ? " · High priority" : ""}
|
||
|
|
</span>
|
||
|
|
<span className="tt-tag-line">
|
||
|
|
{task.tags.entries.map(({ node: tag }) => (
|
||
|
|
<span className="tt-tag" key={tag._qx.ref.$quixosRef}>
|
||
|
|
<i style={{ background: tag.color }} />
|
||
|
|
{tag.name}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</span>
|
||
|
|
{task._qx.relations.completions.aggregate.count > 0n && (
|
||
|
|
<span className="tt-row-meta">
|
||
|
|
{String(task._qx.relations.completions.aggregate.count)} completions
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<div className="tt-page">
|
||
|
|
<button onClick={reset}>First page</button>
|
||
|
|
{page.pageInfo.hasNextPage && <button onClick={() => next(page.pageInfo.endCursor)}>Next 50 tasks →</button>}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function AllRows(props: ListProps) {
|
||
|
|
return <Rows {...props} result={useQuery(queries.Tasks, { root: props.root, variables: props.variables })} />;
|
||
|
|
}
|
||
|
|
function UntaggedRows(props: ListProps) {
|
||
|
|
return <Rows {...props} result={useQuery(queries.UntaggedTasks, { root: props.root, variables: props.variables })} />;
|
||
|
|
}
|
||
|
|
function TaggedRows(props: ListProps & { tags: Tag["_qx"]["ref"][] }) {
|
||
|
|
return (
|
||
|
|
<Rows
|
||
|
|
{...props}
|
||
|
|
result={useQuery(queries.TaggedTasks, { root: props.root, variables: { ...props.variables, tags: props.tags } })}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
export default function ProjectComponent({ camino }: Props) {
|
||
|
|
const [name, setName] = useLiveField(camino.named.fields.name);
|
||
|
|
const root = camino.tasks.$quixosRef;
|
||
|
|
const catalog = useQuery(queries.Catalog, { root, variables: {} });
|
||
|
|
const summary = useQuery(queries.Summary, { root, variables: {} });
|
||
|
|
const [selected, setSelected] = useState<string | null>(null);
|
||
|
|
const [status, setStatus] = useState("OPEN");
|
||
|
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||
|
|
const [untagged, setUntagged] = useState(false);
|
||
|
|
const [after, setAfter] = useState<string | null>(null);
|
||
|
|
const [newTitle, setNewTitle] = useState("");
|
||
|
|
const [newTag, setNewTag] = useState("");
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [error, setError] = useState("");
|
||
|
|
const tags = catalog.status === "ready" ? catalog.data.root.tags.entries.map((e) => e.node) : [];
|
||
|
|
const run = async (action: () => Promise<unknown>) => {
|
||
|
|
if (busy) return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
await action();
|
||
|
|
} catch (e) {
|
||
|
|
setError(e instanceof Error ? e.message : String(e));
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const actions = async () => {
|
||
|
|
const view = await tryConform(root, reactInterfaces.ProjectActions);
|
||
|
|
if (!view) throw new Error("This project no longer supports task actions");
|
||
|
|
return view.call;
|
||
|
|
};
|
||
|
|
const addTask = () =>
|
||
|
|
run(async () => {
|
||
|
|
if (!newTitle.trim()) return;
|
||
|
|
const task = await (await actions())["addTask.call"](newTitle.trim());
|
||
|
|
setNewTitle("");
|
||
|
|
setAfter(null);
|
||
|
|
setStatus("OPEN");
|
||
|
|
setTagIds([]);
|
||
|
|
setUntagged(false);
|
||
|
|
setSelected(task);
|
||
|
|
});
|
||
|
|
const editTag = (tag: Tag, field: "name" | "color", value: string) =>
|
||
|
|
run(async () => {
|
||
|
|
if (!value.trim()) throw new Error("A tag needs a name");
|
||
|
|
const view = await tryConform(tag._qx.ref, reactInterfaces.TagData);
|
||
|
|
if (!view) throw new Error("Tag is no longer available");
|
||
|
|
await view.call[field === "name" ? "name.set" : "color.set"](value);
|
||
|
|
});
|
||
|
|
const choices = tags.map((tag) => ({ id: tag._qx.ref.$quixosRef, name: tag.name, color: tag.color }));
|
||
|
|
const totals = summary.status === "ready" ? summary.data.root._qx.relations.tasks : undefined;
|
||
|
|
const list: ListProps = {
|
||
|
|
root,
|
||
|
|
variables: { first: 50, after, statuses: status === "ALL" ? ["OPEN", "DONE", "ARCHIVED"] : [status] },
|
||
|
|
selected,
|
||
|
|
select: setSelected,
|
||
|
|
next: setAfter,
|
||
|
|
reset: () => {
|
||
|
|
setAfter(null);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const activeTags = tags.filter((t) => tagIds.includes(t._qx.ref.$quixosRef));
|
||
|
|
return (
|
||
|
|
<section className="tt-board">
|
||
|
|
<header className="tt-header">
|
||
|
|
<input
|
||
|
|
aria-label="Project name"
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => {
|
||
|
|
void setName(e.target.value).catch((err) => setError(String(err)));
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<span>{totals ? `${String(totals.aggregate.count)} tasks` : "Task tracker"}</span>
|
||
|
|
</header>
|
||
|
|
{error && (
|
||
|
|
<div role="alert" className="tt-error">
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<form
|
||
|
|
className="tt-add"
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
void addTask();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
aria-label="New task title"
|
||
|
|
placeholder="Add a task"
|
||
|
|
value={newTitle}
|
||
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
||
|
|
disabled={busy}
|
||
|
|
/>
|
||
|
|
<button disabled={busy || !newTitle.trim()} type="submit">
|
||
|
|
Add task
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
<nav className="tt-filters" aria-label="Task filters">
|
||
|
|
<select
|
||
|
|
aria-label="Task status"
|
||
|
|
value={status}
|
||
|
|
onChange={(e) => {
|
||
|
|
setStatus(e.target.value);
|
||
|
|
setAfter(null);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="OPEN">Open</option>
|
||
|
|
<option value="DONE">Done</option>
|
||
|
|
<option value="ARCHIVED">Archived</option>
|
||
|
|
<option value="ALL">All statuses</option>
|
||
|
|
</select>
|
||
|
|
<button
|
||
|
|
aria-pressed={!untagged && tagIds.length === 0}
|
||
|
|
onClick={() => {
|
||
|
|
setTagIds([]);
|
||
|
|
setUntagged(false);
|
||
|
|
setAfter(null);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
All tags
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
aria-pressed={untagged}
|
||
|
|
onClick={() => {
|
||
|
|
setTagIds([]);
|
||
|
|
setUntagged(true);
|
||
|
|
setAfter(null);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Untagged
|
||
|
|
</button>
|
||
|
|
{tags.map((tag) => (
|
||
|
|
<button
|
||
|
|
key={tag._qx.ref.$quixosRef}
|
||
|
|
aria-pressed={tagIds.includes(tag._qx.ref.$quixosRef)}
|
||
|
|
onClick={() => {
|
||
|
|
setUntagged(false);
|
||
|
|
setAfter(null);
|
||
|
|
setTagIds((ids) =>
|
||
|
|
ids.includes(tag._qx.ref.$quixosRef)
|
||
|
|
? ids.filter((id) => id !== tag._qx.ref.$quixosRef)
|
||
|
|
: [...ids, tag._qx.ref.$quixosRef],
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<i style={{ background: tag.color }} />
|
||
|
|
{tag.name}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
{tagIds.length > 1 && <p className="tt-filter-note">Matching any selected tag</p>}
|
||
|
|
{catalog.status === "error" && (
|
||
|
|
<p role="alert" className="tt-error">
|
||
|
|
Tags: {catalog.error.message}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<main className={"tt-body" + (selected ? " has-detail" : "")}>
|
||
|
|
<section className="tt-list">
|
||
|
|
{untagged ? (
|
||
|
|
<UntaggedRows {...list} />
|
||
|
|
) : activeTags.length ? (
|
||
|
|
<TaggedRows {...list} tags={activeTags.map((t) => t._qx.ref)} />
|
||
|
|
) : (
|
||
|
|
<AllRows {...list} />
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
<section className="tt-detail">
|
||
|
|
{selected ? (
|
||
|
|
<>
|
||
|
|
<div className="tt-detail-bar">
|
||
|
|
<button onClick={() => setSelected(null)}>← Task list</button>
|
||
|
|
<a href={`/entity/${encodeURIComponent(selected)}`}>Open separately ↗</a>
|
||
|
|
</div>
|
||
|
|
{/* The queried relation explicitly targets atom Task; query refs carry its TaskData view. */}
|
||
|
|
<TaskEditor
|
||
|
|
forObject={selected as ObjectRef<"atom:task">}
|
||
|
|
tagChoices={choices}
|
||
|
|
style={{ flex: 1, minHeight: 0, height: "100%" }}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className="tt-empty">Select a task to edit it or view completion history.</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
</main>
|
||
|
|
<details className="tt-manage">
|
||
|
|
<summary>Tags & project totals</summary>
|
||
|
|
<div className="tt-management">
|
||
|
|
<section>
|
||
|
|
<h3>Tags</h3>
|
||
|
|
{tags.map((tag) => (
|
||
|
|
<div className="tt-tag-edit" key={tag._qx.ref.$quixosRef}>
|
||
|
|
<input
|
||
|
|
aria-label={`Color for ${tag.name}`}
|
||
|
|
type="color"
|
||
|
|
value={/^#[0-9a-f]{6}$/i.test(tag.color) ? tag.color : "#557a68"}
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => {
|
||
|
|
void editTag(tag, "color", e.target.value);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
aria-label={`Rename ${tag.name}`}
|
||
|
|
defaultValue={tag.name}
|
||
|
|
key={tag.name}
|
||
|
|
disabled={busy}
|
||
|
|
onBlur={(e) => {
|
||
|
|
if (e.target.value !== tag.name) void editTag(tag, "name", e.target.value);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<form
|
||
|
|
className="tt-add"
|
||
|
|
onSubmit={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
void run(async () => {
|
||
|
|
await (await actions())["addTag.call"](newTag);
|
||
|
|
setNewTag("");
|
||
|
|
});
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
aria-label="New tag name"
|
||
|
|
placeholder="New tag"
|
||
|
|
value={newTag}
|
||
|
|
onChange={(e) => setNewTag(e.target.value)}
|
||
|
|
/>
|
||
|
|
<button disabled={busy || !newTag.trim()}>Add tag</button>
|
||
|
|
</form>
|
||
|
|
</section>
|
||
|
|
<section>
|
||
|
|
<h3>Project totals</h3>
|
||
|
|
{summary.status === "error" && <p role="alert">{summary.error.message}</p>}
|
||
|
|
{totals?.groups.entries.map((row) => (
|
||
|
|
<p key={row.group.status}>
|
||
|
|
{row.group.status.toLowerCase()}: <strong>{String(row.aggregate.count)}</strong> ·{" "}
|
||
|
|
{row.aggregate.sum.estimatedHours ?? 0}h estimated
|
||
|
|
</p>
|
||
|
|
))}
|
||
|
|
<h4>Open tasks by tag</h4>
|
||
|
|
{totals?.filter.expand.tags.groups.entries.map((row) => (
|
||
|
|
<p key={row.group.target._qx.ref.$quixosRef}>
|
||
|
|
<i style={{ background: row.group.target.color }} />
|
||
|
|
{row.group.target.name}: {String(row.aggregate.count)} · {row.aggregate.sum.source.estimatedHours ?? 0}h
|
||
|
|
</p>
|
||
|
|
))}
|
||
|
|
<small>A task can contribute to more than one tag. Project totals count each task once.</small>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|