mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 18:33:01 +00:00
frontend indentation set to 2
This commit is contained in:
@@ -11,175 +11,175 @@ import { BREATHING_ROOM } from "@/lib/layout";
|
||||
import { useIssues, useOrganisations, useProjects, useSelectedIssue } from "@/lib/query/hooks";
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
} = useSelection();
|
||||
const location = useLocation();
|
||||
const {
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
} = useSelection();
|
||||
const location = useLocation();
|
||||
|
||||
const deepLinkParams = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
|
||||
const projectKey = params.get("p")?.trim().toLowerCase() ?? "";
|
||||
const issueParam = params.get("i")?.trim() ?? "";
|
||||
const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10);
|
||||
const deepLinkParams = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
|
||||
const projectKey = params.get("p")?.trim().toLowerCase() ?? "";
|
||||
const issueParam = params.get("i")?.trim() ?? "";
|
||||
const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10);
|
||||
|
||||
return {
|
||||
orgSlug,
|
||||
projectKey,
|
||||
issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber,
|
||||
};
|
||||
}, [location.search]);
|
||||
return {
|
||||
orgSlug,
|
||||
projectKey,
|
||||
issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber,
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
||||
const selectedIssue = useSelectedIssue();
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
||||
const selectedIssue = useSelectedIssue();
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
|
||||
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
||||
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
||||
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
||||
const findOrgBySlug = (slug: string) =>
|
||||
organisations.find((org) => org.Organisation.slug.toLowerCase() === slug) ?? null;
|
||||
const findProjectByKey = (key: string) =>
|
||||
projects.find((project) => project.Project.key.toLowerCase() === key) ?? null;
|
||||
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
||||
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
||||
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
||||
const findOrgBySlug = (slug: string) =>
|
||||
organisations.find((org) => org.Organisation.slug.toLowerCase() === slug) ?? null;
|
||||
const findProjectByKey = (key: string) =>
|
||||
projects.find((project) => project.Project.key.toLowerCase() === key) ?? null;
|
||||
|
||||
const deepLinkActive = deepLinkParams.projectKey !== "" || deepLinkParams.issueNumber != null;
|
||||
const deepLinkFlowRef = useRef({
|
||||
stage: "idle" as "idle" | "org" | "project" | "issue" | "done",
|
||||
orgSlug: "",
|
||||
projectKey: "",
|
||||
issueNumber: null as number | null,
|
||||
targetOrgId: null as number | null,
|
||||
targetProjectId: null as number | null,
|
||||
});
|
||||
const deepLinkActive = deepLinkParams.projectKey !== "" || deepLinkParams.issueNumber != null;
|
||||
const deepLinkFlowRef = useRef({
|
||||
stage: "idle" as "idle" | "org" | "project" | "issue" | "done",
|
||||
orgSlug: "",
|
||||
projectKey: "",
|
||||
issueNumber: null as number | null,
|
||||
targetOrgId: null as number | null,
|
||||
targetProjectId: null as number | null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
deepLinkFlowRef.current = {
|
||||
stage: deepLinkActive ? "org" : "idle",
|
||||
orgSlug: deepLinkParams.orgSlug,
|
||||
projectKey: deepLinkParams.projectKey,
|
||||
issueNumber: deepLinkParams.issueNumber,
|
||||
targetOrgId: null,
|
||||
targetProjectId: null,
|
||||
};
|
||||
}, [deepLinkActive, deepLinkParams.orgSlug, deepLinkParams.projectKey, deepLinkParams.issueNumber]);
|
||||
useEffect(() => {
|
||||
deepLinkFlowRef.current = {
|
||||
stage: deepLinkActive ? "org" : "idle",
|
||||
orgSlug: deepLinkParams.orgSlug,
|
||||
projectKey: deepLinkParams.projectKey,
|
||||
issueNumber: deepLinkParams.issueNumber,
|
||||
targetOrgId: null,
|
||||
targetProjectId: null,
|
||||
};
|
||||
}, [deepLinkActive, deepLinkParams.orgSlug, deepLinkParams.projectKey, deepLinkParams.issueNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organisations.length === 0) return;
|
||||
useEffect(() => {
|
||||
if (organisations.length === 0) return;
|
||||
|
||||
if (deepLinkActive && deepLinkFlowRef.current.stage !== "org") {
|
||||
return;
|
||||
}
|
||||
if (deepLinkActive && deepLinkFlowRef.current.stage !== "org") {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = findById(organisations, selectedOrganisationId, (org) => org.Organisation.id);
|
||||
if (deepLinkActive && deepLinkFlowRef.current.orgSlug) {
|
||||
selected = findOrgBySlug(deepLinkFlowRef.current.orgSlug) ?? selected;
|
||||
}
|
||||
selected = selectFallback(organisations, selected);
|
||||
let selected = findById(organisations, selectedOrganisationId, (org) => org.Organisation.id);
|
||||
if (deepLinkActive && deepLinkFlowRef.current.orgSlug) {
|
||||
selected = findOrgBySlug(deepLinkFlowRef.current.orgSlug) ?? selected;
|
||||
}
|
||||
selected = selectFallback(organisations, selected);
|
||||
|
||||
if (!selected) return;
|
||||
if (!selected) return;
|
||||
|
||||
if (deepLinkActive) {
|
||||
deepLinkFlowRef.current.targetOrgId = selected.Organisation.id;
|
||||
deepLinkFlowRef.current.stage = "project";
|
||||
if (selected.Organisation.id !== selectedOrganisationId) {
|
||||
selectOrganisation(selected, { skipUrlUpdate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (deepLinkActive) {
|
||||
deepLinkFlowRef.current.targetOrgId = selected.Organisation.id;
|
||||
deepLinkFlowRef.current.stage = "project";
|
||||
if (selected.Organisation.id !== selectedOrganisationId) {
|
||||
selectOrganisation(selected, { skipUrlUpdate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Organisation.id !== selectedOrganisationId) {
|
||||
selectOrganisation(selected);
|
||||
}
|
||||
}, [organisations, selectedOrganisationId, deepLinkActive, selectOrganisation]);
|
||||
if (selected.Organisation.id !== selectedOrganisationId) {
|
||||
selectOrganisation(selected);
|
||||
}
|
||||
}, [organisations, selectedOrganisationId, deepLinkActive, selectOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
if (!deepLinkActive && selectedProjectId == null) {
|
||||
selectProject(projects[0]);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
if (!deepLinkActive && selectedProjectId == null) {
|
||||
selectProject(projects[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deepLinkActive) {
|
||||
const flow = deepLinkFlowRef.current;
|
||||
if (flow.stage !== "project") return;
|
||||
if (flow.targetOrgId != null && selectedOrganisationId !== flow.targetOrgId) {
|
||||
return;
|
||||
}
|
||||
let selected = findById(projects, selectedProjectId, (project) => project.Project.id);
|
||||
if (flow.projectKey) {
|
||||
selected = findProjectByKey(flow.projectKey) ?? selected;
|
||||
}
|
||||
selected = selectFallback(projects, selected);
|
||||
if (!selected) return;
|
||||
flow.targetProjectId = selected.Project.id;
|
||||
flow.stage = "issue";
|
||||
if (selected.Project.id !== selectedProjectId) {
|
||||
selectProject(selected, { skipUrlUpdate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (deepLinkActive) {
|
||||
const flow = deepLinkFlowRef.current;
|
||||
if (flow.stage !== "project") return;
|
||||
if (flow.targetOrgId != null && selectedOrganisationId !== flow.targetOrgId) {
|
||||
return;
|
||||
}
|
||||
let selected = findById(projects, selectedProjectId, (project) => project.Project.id);
|
||||
if (flow.projectKey) {
|
||||
selected = findProjectByKey(flow.projectKey) ?? selected;
|
||||
}
|
||||
selected = selectFallback(projects, selected);
|
||||
if (!selected) return;
|
||||
flow.targetProjectId = selected.Project.id;
|
||||
flow.stage = "issue";
|
||||
if (selected.Project.id !== selectedProjectId) {
|
||||
selectProject(selected, { skipUrlUpdate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = findById(projects, selectedProjectId, (project) => project.Project.id);
|
||||
selected = selectFallback(projects, selected);
|
||||
if (selected && selected.Project.id !== selectedProjectId) {
|
||||
selectProject(selected);
|
||||
}
|
||||
}, [projects, selectedProjectId, selectedOrganisationId, deepLinkActive, selectProject]);
|
||||
let selected = findById(projects, selectedProjectId, (project) => project.Project.id);
|
||||
selected = selectFallback(projects, selected);
|
||||
if (selected && selected.Project.id !== selectedProjectId) {
|
||||
selectProject(selected);
|
||||
}
|
||||
}, [projects, selectedProjectId, selectedOrganisationId, deepLinkActive, selectProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deepLinkActive) return;
|
||||
const flow = deepLinkFlowRef.current;
|
||||
if (flow.stage !== "issue") return;
|
||||
if (flow.targetProjectId != null && selectedProjectId !== flow.targetProjectId) {
|
||||
return;
|
||||
}
|
||||
if (!issuesFetched) return;
|
||||
if (flow.issueNumber != null) {
|
||||
const match = issuesData.find((issue) => issue.Issue.number === flow.issueNumber);
|
||||
if (match && match.Issue.id !== selectedIssueId) {
|
||||
selectIssue(match, { skipUrlUpdate: true });
|
||||
}
|
||||
}
|
||||
flow.stage = "done";
|
||||
}, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]);
|
||||
useEffect(() => {
|
||||
if (!deepLinkActive) return;
|
||||
const flow = deepLinkFlowRef.current;
|
||||
if (flow.stage !== "issue") return;
|
||||
if (flow.targetProjectId != null && selectedProjectId !== flow.targetProjectId) {
|
||||
return;
|
||||
}
|
||||
if (!issuesFetched) return;
|
||||
if (flow.issueNumber != null) {
|
||||
const match = issuesData.find((issue) => issue.Issue.number === flow.issueNumber);
|
||||
if (match && match.Issue.id !== selectedIssueId) {
|
||||
selectIssue(match, { skipUrlUpdate: true });
|
||||
}
|
||||
}
|
||||
flow.stage = "done";
|
||||
}, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]);
|
||||
|
||||
return (
|
||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||
<TopBar />
|
||||
return (
|
||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||
<TopBar />
|
||||
|
||||
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
||||
<ResizablePanelGroup className={`flex-1`}>
|
||||
<ResizablePanel id={"left"} minSize={400}>
|
||||
<IssuesTable columns={{ description: false }} className="border w-full flex-shrink" />
|
||||
</ResizablePanel>
|
||||
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
||||
<ResizablePanelGroup className={`flex-1`}>
|
||||
<ResizablePanel id={"left"} minSize={400}>
|
||||
<IssuesTable columns={{ description: false }} className="border w-full flex-shrink" />
|
||||
</ResizablePanel>
|
||||
|
||||
{selectedIssue && (
|
||||
<>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={363} maxSize={"60%"}>
|
||||
<div className="border">
|
||||
<IssueDetailPane />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
{selectedIssue && (
|
||||
<>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={363} maxSize={"60%"}>
|
||||
<div className="border">
|
||||
<IssueDetailPane />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
const WEIGHTS = [
|
||||
200, 225, 250, 275, 300, 325, 350, 375, 400, 425, 450, 475, 500, 525, 550, 575, 600, 625, 650, 675, 700,
|
||||
200, 225, 250, 275, 300, 325, 350, 375, 400, 425, 450, 475, 500, 525, 550, 575, 600, 625, 650, 675, 700,
|
||||
];
|
||||
|
||||
const SAMPLE_TEXT = "The quick brown fox jumps over the lazy dog 0123456789.";
|
||||
|
||||
export default function Font() {
|
||||
return (
|
||||
<main className="min-h-screen font-mono">
|
||||
<div className="mx-auto w-fit px-8 py-10">
|
||||
<h1 className="text-2xl font-500">Commit Mono</h1>
|
||||
<div className="mt-6 flex flex-col divide-y divide-foreground/30">
|
||||
{WEIGHTS.map((weight) => (
|
||||
<div key={weight} className="grid grid-cols-[42px_1fr] items-center gap-3 px-3 py-3">
|
||||
<span className="text-sm" style={{ fontWeight: weight }}>
|
||||
{weight}
|
||||
</span>
|
||||
<span style={{ fontWeight: weight }}>{SAMPLE_TEXT}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<main className="min-h-screen font-mono">
|
||||
<div className="mx-auto w-fit px-8 py-10">
|
||||
<h1 className="text-2xl font-500">Commit Mono</h1>
|
||||
<div className="mt-6 flex flex-col divide-y divide-foreground/30">
|
||||
{WEIGHTS.map((weight) => (
|
||||
<div key={weight} className="grid grid-cols-[42px_1fr] items-center gap-3 px-3 py-3">
|
||||
<span className="text-sm" style={{ fontWeight: weight }}>
|
||||
{weight}
|
||||
</span>
|
||||
<span style={{ fontWeight: weight }}>{SAMPLE_TEXT}</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,91 +5,87 @@ import ThemeToggle from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Landing() {
|
||||
const { user, isLoading } = useSession();
|
||||
const { user, isLoading } = useSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="relative flex items-center justify-center p-2 border-b">
|
||||
<div className="text-3xl font-basteleur font-700">Sprint</div>
|
||||
<nav className="absolute right-2 flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
{!isLoading && user ? (
|
||||
<>
|
||||
{user && (
|
||||
<h1 className="text-xl font-basteleur font-400">
|
||||
Welcome back {user.name.split(" ")[0]}!
|
||||
</h1>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Sign in</Link>
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="relative flex items-center justify-center p-2 border-b">
|
||||
<div className="text-3xl font-basteleur font-700">Sprint</div>
|
||||
<nav className="absolute right-2 flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
{!isLoading && user ? (
|
||||
<>
|
||||
{user && (
|
||||
<h1 className="text-xl font-basteleur font-400">Welcome back {user.name.split(" ")[0]}!</h1>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Sign in</Link>
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<div className="max-w-3xl text-center space-y-4">
|
||||
<h1 className="text-[54px] font-basteleur font-700">
|
||||
Need a snappy project management tool?
|
||||
</h1>
|
||||
<p className="text-[24px] font-goudy text-muted-foreground">
|
||||
Build your next project with <span className="font-goudy font-700">Sprint.</span>
|
||||
</p>
|
||||
<p className="text-[18px] font-goudy text-muted-foreground font-700">
|
||||
Sick of Jira? Say hello to your new favorite project management tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
{!isLoading && user ? (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/login">Get started</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="inline-flex gap-2 items-center">
|
||||
<span className="relative">
|
||||
<a
|
||||
href="https://github.com/hex248/issue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex gap-2 text-muted-foreground hover:text-personality"
|
||||
>
|
||||
<Icon icon="mdi:github" className="h-7 w-7" />
|
||||
<span className="font-goudy font-700 text-2xl">GitHub</span>
|
||||
</a>
|
||||
<span className="text-violet-400/90 absolute left-full top-[45%] ml-4 -translate-y-1/2 whitespace-nowrap select-none text-muted-foreground">
|
||||
{"<-- you can self-host me!"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-1 border-t">
|
||||
<span className="font-300 text-lg text-muted-foreground font-goudy">
|
||||
Built by{" "}
|
||||
<a
|
||||
href="https://ob248.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-personality font-goudy font-700"
|
||||
>
|
||||
Oliver Bryan
|
||||
</a>
|
||||
</span>
|
||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
|
||||
</a>
|
||||
</footer>
|
||||
<main className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<div className="max-w-3xl text-center space-y-4">
|
||||
<h1 className="text-[54px] font-basteleur font-700">Need a snappy project management tool?</h1>
|
||||
<p className="text-[24px] font-goudy text-muted-foreground">
|
||||
Build your next project with <span className="font-goudy font-700">Sprint.</span>
|
||||
</p>
|
||||
<p className="text-[18px] font-goudy text-muted-foreground font-700">
|
||||
Sick of Jira? Say hello to your new favorite project management tool.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
{!isLoading && user ? (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/login">Get started</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="inline-flex gap-2 items-center">
|
||||
<span className="relative">
|
||||
<a
|
||||
href="https://github.com/hex248/issue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex gap-2 text-muted-foreground hover:text-personality"
|
||||
>
|
||||
<Icon icon="mdi:github" className="h-7 w-7" />
|
||||
<span className="font-goudy font-700 text-2xl">GitHub</span>
|
||||
</a>
|
||||
<span className="text-violet-400/90 absolute left-full top-[45%] ml-4 -translate-y-1/2 whitespace-nowrap select-none text-muted-foreground">
|
||||
{"<-- you can self-host me!"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-1 border-t">
|
||||
<span className="font-300 text-lg text-muted-foreground font-goudy">
|
||||
Built by{" "}
|
||||
<a
|
||||
href="https://ob248.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-personality font-goudy font-700"
|
||||
>
|
||||
Oliver Bryan
|
||||
</a>
|
||||
</span>
|
||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,28 +5,28 @@ import LogInForm from "@/components/login-form";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, isLoading } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, isLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
}, [user, isLoading, navigate, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="Checking authentication" />;
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
}, [user, isLoading, navigate, searchParams]);
|
||||
|
||||
if (user) {
|
||||
return <Loading message="Redirecting" />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <Loading message="Checking authentication" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<LogInForm />
|
||||
</div>
|
||||
);
|
||||
if (user) {
|
||||
return <Loading message="Redirecting" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<LogInForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Icon from "@/components/ui/icon";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}>
|
||||
<Icon icon="circleQuestionMark" size={72} />
|
||||
<span className="text-7xl font-500">404</span>
|
||||
<span className="text-2xl font-400">Not Found</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}>
|
||||
<Icon icon="circleQuestionMark" size={72} />
|
||||
<span className="text-7xl font-500">404</span>
|
||||
<span className="text-2xl font-400">Not Found</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,56 +6,56 @@ import ColourPicker from "@/components/ui/colour-picker";
|
||||
import Icon, { iconNames, iconStyles } from "@/components/ui/icon";
|
||||
|
||||
function Test() {
|
||||
const [colour, setColour] = useState("#e05656");
|
||||
const [colour, setColour] = useState("#e05656");
|
||||
|
||||
return (
|
||||
<main className="w-full min-h-[100vh] flex justify-center items-center gap-32 p-4">
|
||||
<div className="mt-8 flex flex-col items-center gap-4">
|
||||
<p className="text-muted-foreground">Other test components</p>
|
||||
<div className="flex gap-4">
|
||||
<Button linkTo="/">go back to "/"</Button>
|
||||
</div>
|
||||
<LogOutButton />
|
||||
<ColourPicker colour={colour} onChange={setColour} />
|
||||
return (
|
||||
<main className="w-full min-h-[100vh] flex justify-center items-center gap-32 p-4">
|
||||
<div className="mt-8 flex flex-col items-center gap-4">
|
||||
<p className="text-muted-foreground">Other test components</p>
|
||||
<div className="flex gap-4">
|
||||
<Button linkTo="/">go back to "/"</Button>
|
||||
</div>
|
||||
<LogOutButton />
|
||||
<ColourPicker colour={colour} onChange={setColour} />
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Icon Demo</h1>
|
||||
<p className="text-muted-foreground">
|
||||
All {iconNames.length} icons across {iconStyles.length} styles
|
||||
</p>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Icon Demo</h1>
|
||||
<p className="text-muted-foreground">
|
||||
All {iconNames.length} icons across {iconStyles.length} styles
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 font-medium">Name</th>
|
||||
{iconStyles.map((iconStyle) => (
|
||||
<th key={iconStyle} className="text-center p-2 font-medium capitalize">
|
||||
{iconStyle}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{iconNames.map((name) => (
|
||||
<tr key={name} className="border-b hover:bg-muted/50">
|
||||
<td className="font-mono text-sm pl-2 pr-12">{name}</td>
|
||||
{iconStyles.map((iconStyle) => (
|
||||
<td key={iconStyle} className="p-2 text-center">
|
||||
<Icon icon={name} iconStyle={iconStyle} size={24} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 font-medium">Name</th>
|
||||
{iconStyles.map((iconStyle) => (
|
||||
<th key={iconStyle} className="text-center p-2 font-medium capitalize">
|
||||
{iconStyle}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{iconNames.map((name) => (
|
||||
<tr key={name} className="border-b hover:bg-muted/50">
|
||||
<td className="font-mono text-sm pl-2 pr-12">{name}</td>
|
||||
{iconStyles.map((iconStyle) => (
|
||||
<td key={iconStyle} className="p-2 text-center">
|
||||
<Icon icon={name} iconStyle={iconStyle} size={24} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Test;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
DEFAULT_SPRINT_COLOUR,
|
||||
DEFAULT_STATUS_COLOUR,
|
||||
type IssueResponse,
|
||||
type SprintRecord,
|
||||
DEFAULT_SPRINT_COLOUR,
|
||||
DEFAULT_STATUS_COLOUR,
|
||||
type IssueResponse,
|
||||
type SprintRecord,
|
||||
} from "@sprint/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IssueModal } from "@/components/issue-modal";
|
||||
@@ -12,11 +12,11 @@ import StatusTag from "@/components/status-tag";
|
||||
import TopBar from "@/components/top-bar";
|
||||
import { BREATHING_ROOM } from "@/lib/layout";
|
||||
import {
|
||||
useIssues,
|
||||
useOrganisations,
|
||||
useProjects,
|
||||
useSelectedOrganisation,
|
||||
useSprints,
|
||||
useIssues,
|
||||
useOrganisations,
|
||||
useProjects,
|
||||
useSelectedOrganisation,
|
||||
useSprints,
|
||||
} from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -24,386 +24,364 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_LABEL_WIDTH = "240px";
|
||||
|
||||
const addDays = (value: Date, days: number) =>
|
||||
new Date(value.getFullYear(), value.getMonth(), value.getDate() + days);
|
||||
new Date(value.getFullYear(), value.getMonth(), value.getDate() + days);
|
||||
|
||||
const toDate = (value: Date | string) => {
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
|
||||
};
|
||||
|
||||
const formatDate = (value: Date | string) =>
|
||||
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
|
||||
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
|
||||
|
||||
const formatWeekLabel = (value: Date) =>
|
||||
value.toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
|
||||
value.toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
|
||||
|
||||
const formatTodayLabel = (value: Date) => {
|
||||
const parts = new Intl.DateTimeFormat(undefined, { month: "short", day: "2-digit" }).formatToParts(value);
|
||||
const month = parts.find((part) => part.type === "month")?.value ?? "";
|
||||
const day = parts.find((part) => part.type === "day")?.value ?? "";
|
||||
return `${day} ${month}`.trim().toUpperCase();
|
||||
const parts = new Intl.DateTimeFormat(undefined, { month: "short", day: "2-digit" }).formatToParts(value);
|
||||
const month = parts.find((part) => part.type === "month")?.value ?? "";
|
||||
const day = parts.find((part) => part.type === "day")?.value ?? "";
|
||||
return `${day} ${month}`.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const getSprintDateRange = (sprint: SprintRecord) => {
|
||||
return `${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`;
|
||||
return `${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`;
|
||||
};
|
||||
|
||||
type IssueGroup = {
|
||||
issuesBySprint: Map<number, IssueResponse[]>;
|
||||
unassigned: IssueResponse[];
|
||||
issuesBySprint: Map<number, IssueResponse[]>;
|
||||
unassigned: IssueResponse[];
|
||||
};
|
||||
|
||||
type TimelineRange = {
|
||||
start: Date;
|
||||
end: Date;
|
||||
durationMs: number;
|
||||
start: Date;
|
||||
end: Date;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export default function Timeline() {
|
||||
const { selectedOrganisationId, selectedProjectId, selectOrganisation, selectProject } = useSelection();
|
||||
const { selectedOrganisationId, selectedProjectId, selectOrganisation, selectProject } = useSelection();
|
||||
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: sprintsData = [] } = useSprints(selectedProjectId);
|
||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: sprintsData = [] } = useSprints(selectedProjectId);
|
||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
|
||||
const sprints = useMemo(
|
||||
() =>
|
||||
[...sprintsData].sort((a, b) => {
|
||||
const aStart = a.startDate ? new Date(a.startDate).getTime() : null;
|
||||
const bStart = b.startDate ? new Date(b.startDate).getTime() : null;
|
||||
if (aStart != null && bStart != null) return aStart - bStart;
|
||||
if (aStart == null && bStart == null) return a.name.localeCompare(b.name);
|
||||
return aStart == null ? 1 : -1;
|
||||
}),
|
||||
[sprintsData],
|
||||
);
|
||||
const sprints = useMemo(
|
||||
() =>
|
||||
[...sprintsData].sort((a, b) => {
|
||||
const aStart = a.startDate ? new Date(a.startDate).getTime() : null;
|
||||
const bStart = b.startDate ? new Date(b.startDate).getTime() : null;
|
||||
if (aStart != null && bStart != null) return aStart - bStart;
|
||||
if (aStart == null && bStart == null) return a.name.localeCompare(b.name);
|
||||
return aStart == null ? 1 : -1;
|
||||
}),
|
||||
[sprintsData],
|
||||
);
|
||||
|
||||
const issueGroup = useMemo<IssueGroup>(() => {
|
||||
const grouped = new Map<number, IssueResponse[]>();
|
||||
const unassigned: IssueResponse[] = [];
|
||||
const issueGroup = useMemo<IssueGroup>(() => {
|
||||
const grouped = new Map<number, IssueResponse[]>();
|
||||
const unassigned: IssueResponse[] = [];
|
||||
|
||||
for (const issue of issuesData) {
|
||||
const sprintId = issue.Issue.sprintId;
|
||||
if (!sprintId) {
|
||||
unassigned.push(issue);
|
||||
continue;
|
||||
}
|
||||
const current = grouped.get(sprintId);
|
||||
if (current) current.push(issue);
|
||||
else grouped.set(sprintId, [issue]);
|
||||
}
|
||||
for (const issue of issuesData) {
|
||||
const sprintId = issue.Issue.sprintId;
|
||||
if (!sprintId) {
|
||||
unassigned.push(issue);
|
||||
continue;
|
||||
}
|
||||
const current = grouped.get(sprintId);
|
||||
if (current) current.push(issue);
|
||||
else grouped.set(sprintId, [issue]);
|
||||
}
|
||||
|
||||
for (const [sprintId, issues] of grouped.entries()) {
|
||||
grouped.set(
|
||||
sprintId,
|
||||
[...issues].sort((a, b) => a.Issue.number - b.Issue.number),
|
||||
);
|
||||
}
|
||||
for (const [sprintId, issues] of grouped.entries()) {
|
||||
grouped.set(
|
||||
sprintId,
|
||||
[...issues].sort((a, b) => a.Issue.number - b.Issue.number),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issuesBySprint: grouped,
|
||||
unassigned: [...unassigned].sort((a, b) => a.Issue.number - b.Issue.number),
|
||||
};
|
||||
}, [issuesData]);
|
||||
|
||||
const timelineRange = useMemo<TimelineRange | null>(() => {
|
||||
if (sprints.length === 0) return null;
|
||||
const today = toDate(new Date());
|
||||
let earliest = toDate(sprints[0].startDate);
|
||||
let latest = toDate(sprints[0].endDate);
|
||||
|
||||
for (const sprint of sprints) {
|
||||
const start = toDate(sprint.startDate);
|
||||
const end = toDate(sprint.endDate);
|
||||
if (start < earliest) earliest = start;
|
||||
if (end > latest) latest = end;
|
||||
}
|
||||
|
||||
const rangeStart = today;
|
||||
const rangeEnd = addDays(today, 60);
|
||||
const durationMs = rangeEnd.getTime() - rangeStart.getTime() + DAY_MS;
|
||||
|
||||
return { start: rangeStart, end: rangeEnd, durationMs };
|
||||
}, [sprints]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
if (!timelineRange) return [] as Date[];
|
||||
const output: Date[] = [];
|
||||
let cursor = new Date(timelineRange.start);
|
||||
while (cursor <= timelineRange.end) {
|
||||
output.push(new Date(cursor));
|
||||
cursor = addDays(cursor, 7);
|
||||
}
|
||||
return output;
|
||||
}, [timelineRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organisations.length === 0) return;
|
||||
const selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
|
||||
if (!selected) {
|
||||
selectOrganisation(organisations[0]);
|
||||
}
|
||||
}, [organisations, selectedOrganisationId, selectOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
const selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
|
||||
if (!selected) {
|
||||
selectProject(projects[0]);
|
||||
}
|
||||
}, [projects, selectedProjectId, selectProject]);
|
||||
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH} 1fr`;
|
||||
return `${TIMELINE_LABEL_WIDTH} repeat(${weeks.length}, minmax(140px, 1fr))`;
|
||||
}, [weeks.length]);
|
||||
|
||||
const todayMarker = useMemo(() => {
|
||||
if (!timelineRange) return null;
|
||||
const today = toDate(new Date());
|
||||
if (today < timelineRange.start || today > timelineRange.end) return null;
|
||||
const left = ((today.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
return { left: `${left}%`, label: formatTodayLabel(today) };
|
||||
}, [timelineRange]);
|
||||
|
||||
const getSprintBarStyle = (sprint: SprintRecord) => {
|
||||
if (!timelineRange) return null;
|
||||
const start = toDate(sprint.startDate);
|
||||
const end = addDays(toDate(sprint.endDate), 1);
|
||||
const left = ((start.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
const right = ((end.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
const width = Math.max(right - left, 1);
|
||||
return {
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: sprint.color || DEFAULT_SPRINT_COLOUR,
|
||||
};
|
||||
return {
|
||||
issuesBySprint: grouped,
|
||||
unassigned: [...unassigned].sort((a, b) => a.Issue.number - b.Issue.number),
|
||||
};
|
||||
}, [issuesData]);
|
||||
|
||||
return (
|
||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||
<TopBar />
|
||||
const timelineRange = useMemo<TimelineRange | null>(() => {
|
||||
if (sprints.length === 0) return null;
|
||||
const today = toDate(new Date());
|
||||
let earliest = toDate(sprints[0].startDate);
|
||||
let latest = toDate(sprints[0].endDate);
|
||||
|
||||
<div className={`flex-1 flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{!selectedOrganisationId && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
Select an organisation to view its sprint schedule.
|
||||
for (const sprint of sprints) {
|
||||
const start = toDate(sprint.startDate);
|
||||
const end = toDate(sprint.endDate);
|
||||
if (start < earliest) earliest = start;
|
||||
if (end > latest) latest = end;
|
||||
}
|
||||
|
||||
const rangeStart = today;
|
||||
const rangeEnd = addDays(today, 60);
|
||||
const durationMs = rangeEnd.getTime() - rangeStart.getTime() + DAY_MS;
|
||||
|
||||
return { start: rangeStart, end: rangeEnd, durationMs };
|
||||
}, [sprints]);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
if (!timelineRange) return [] as Date[];
|
||||
const output: Date[] = [];
|
||||
let cursor = new Date(timelineRange.start);
|
||||
while (cursor <= timelineRange.end) {
|
||||
output.push(new Date(cursor));
|
||||
cursor = addDays(cursor, 7);
|
||||
}
|
||||
return output;
|
||||
}, [timelineRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organisations.length === 0) return;
|
||||
const selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
|
||||
if (!selected) {
|
||||
selectOrganisation(organisations[0]);
|
||||
}
|
||||
}, [organisations, selectedOrganisationId, selectOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
const selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
|
||||
if (!selected) {
|
||||
selectProject(projects[0]);
|
||||
}
|
||||
}, [projects, selectedProjectId, selectProject]);
|
||||
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH} 1fr`;
|
||||
return `${TIMELINE_LABEL_WIDTH} repeat(${weeks.length}, minmax(140px, 1fr))`;
|
||||
}, [weeks.length]);
|
||||
|
||||
const todayMarker = useMemo(() => {
|
||||
if (!timelineRange) return null;
|
||||
const today = toDate(new Date());
|
||||
if (today < timelineRange.start || today > timelineRange.end) return null;
|
||||
const left = ((today.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
return { left: `${left}%`, label: formatTodayLabel(today) };
|
||||
}, [timelineRange]);
|
||||
|
||||
const getSprintBarStyle = (sprint: SprintRecord) => {
|
||||
if (!timelineRange) return null;
|
||||
const start = toDate(sprint.startDate);
|
||||
const end = addDays(toDate(sprint.endDate), 1);
|
||||
const left = ((start.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
const right = ((end.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100;
|
||||
const width = Math.max(right - left, 1);
|
||||
return {
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: sprint.color || DEFAULT_SPRINT_COLOUR,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||
<TopBar />
|
||||
|
||||
<div className={`flex-1 flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{!selectedOrganisationId && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
Select an organisation to view its sprint schedule.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrganisationId && !selectedProjectId && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
Pick a project to view its sprint timeline.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrganisationId && selectedProjectId && sprints.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
No sprints yet. Create a sprint from the organisations menu to start planning work.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrganisationId && selectedProjectId && sprints.length > 0 && (
|
||||
<div className="border">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-background border-r`}
|
||||
>
|
||||
Sprint
|
||||
</div>
|
||||
{weeks.map((week) => (
|
||||
<div
|
||||
key={week.toISOString()}
|
||||
className={cn(
|
||||
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs text-muted-foreground tabular-nums`,
|
||||
"border-l",
|
||||
)}
|
||||
>
|
||||
{formatWeekLabel(week)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedOrganisationId && !selectedProjectId && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
Pick a project to view its sprint timeline.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrganisationId && selectedProjectId && sprints.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-pretty">
|
||||
No sprints yet. Create a sprint from the organisations menu to start planning work.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrganisationId && selectedProjectId && sprints.length > 0 && (
|
||||
<div className="border">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-background border-r`}
|
||||
>
|
||||
Sprint
|
||||
</div>
|
||||
{weeks.map((week) => (
|
||||
<div
|
||||
key={week.toISOString()}
|
||||
className={cn(
|
||||
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs text-muted-foreground tabular-nums`,
|
||||
"border-l",
|
||||
)}
|
||||
>
|
||||
{formatWeekLabel(week)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sprints.map((sprint, sprintIndex) => {
|
||||
const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? [];
|
||||
const barStyle = getSprintBarStyle(sprint);
|
||||
const showTodayLabel = sprintIndex === 0;
|
||||
return (
|
||||
<div
|
||||
key={sprint.id}
|
||||
className="grid border-b"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-3`}>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: sprint.color || DEFAULT_SPRINT_COLOUR,
|
||||
}}
|
||||
>
|
||||
{sprint.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{getSprintDateRange(sprint)}
|
||||
</div>
|
||||
{sprintIssues.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-pretty">
|
||||
No issues assigned.
|
||||
</div>
|
||||
)}
|
||||
{sprintIssues.length > 0 && (
|
||||
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{sprintIssues.map((issue) => (
|
||||
<IssueLine
|
||||
key={issue.Issue.id}
|
||||
issue={issue}
|
||||
statusColour={
|
||||
statuses[issue.Issue.status] ??
|
||||
DEFAULT_STATUS_COLOUR
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
`py-${BREATHING_ROOM} relative min-h-12`,
|
||||
"border-l",
|
||||
)}
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
<div className="absolute inset-0 flex z-10 pointer-events-none">
|
||||
{weeks.map((week, index) => (
|
||||
<div
|
||||
key={`${week.toISOString()}-${sprint.id}`}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
index === 0 ? "" : "border-l",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{todayMarker && (
|
||||
<div
|
||||
className="absolute inset-y-0 z-20 pointer-events-none"
|
||||
style={{ left: todayMarker.left }}
|
||||
>
|
||||
<div className="absolute inset-y-0 w-px bg-primary" />
|
||||
{showTodayLabel && (
|
||||
<div className="absolute -top-5.5 -translate-x-1/2">
|
||||
<span className="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold text-primary-foreground whitespace-nowrap">
|
||||
{todayMarker.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{barStyle && (
|
||||
<SprintForm
|
||||
mode="edit"
|
||||
existingSprint={sprint}
|
||||
sprints={sprints}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit sprint ${sprint.name}`}
|
||||
className="absolute top-1/2 z-0 h-4 rounded border border-foreground/10 cursor-pointer"
|
||||
style={barStyle}
|
||||
title={`${sprint.name}: ${getSprintDateRange(sprint)}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`}
|
||||
>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Backlog
|
||||
</div>
|
||||
{issueGroup.unassigned.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-pretty">
|
||||
No unassigned issues.
|
||||
</div>
|
||||
)}
|
||||
{issueGroup.unassigned.length > 0 && (
|
||||
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{issueGroup.unassigned.map((issue) => (
|
||||
<IssueLine
|
||||
key={issue.Issue.id}
|
||||
issue={issue}
|
||||
statusColour={
|
||||
statuses[issue.Issue.status] ??
|
||||
DEFAULT_STATUS_COLOUR
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} border-l text-xs text-muted-foreground`,
|
||||
)}
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{sprints.map((sprint, sprintIndex) => {
|
||||
const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? [];
|
||||
const barStyle = getSprintBarStyle(sprint);
|
||||
const showTodayLabel = sprintIndex === 0;
|
||||
return (
|
||||
<div key={sprint.id} className="grid border-b" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-3`}>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: sprint.color || DEFAULT_SPRINT_COLOUR,
|
||||
}}
|
||||
>
|
||||
{sprint.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{getSprintDateRange(sprint)}
|
||||
</div>
|
||||
{sprintIssues.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-pretty">No issues assigned.</div>
|
||||
)}
|
||||
{sprintIssues.length > 0 && (
|
||||
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{sprintIssues.map((issue) => (
|
||||
<IssueLine
|
||||
key={issue.Issue.id}
|
||||
issue={issue}
|
||||
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(`py-${BREATHING_ROOM} relative min-h-12`, "border-l")}
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
<div className="absolute inset-0 flex z-10 pointer-events-none">
|
||||
{weeks.map((week, index) => (
|
||||
<div
|
||||
key={`${week.toISOString()}-${sprint.id}`}
|
||||
className={cn("flex-1", index === 0 ? "" : "border-l")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{todayMarker && (
|
||||
<div
|
||||
className="absolute inset-y-0 z-20 pointer-events-none"
|
||||
style={{ left: todayMarker.left }}
|
||||
>
|
||||
<div className="absolute inset-y-0 w-px bg-primary" />
|
||||
{showTodayLabel && (
|
||||
<div className="absolute -top-5.5 -translate-x-1/2">
|
||||
<span className="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold text-primary-foreground whitespace-nowrap">
|
||||
{todayMarker.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{barStyle && (
|
||||
<SprintForm
|
||||
mode="edit"
|
||||
existingSprint={sprint}
|
||||
sprints={sprints}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit sprint ${sprint.name}`}
|
||||
className="absolute top-1/2 z-0 h-4 rounded border border-foreground/10 cursor-pointer"
|
||||
style={barStyle}
|
||||
title={`${sprint.name}: ${getSprintDateRange(sprint)}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`}
|
||||
>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backlog</div>
|
||||
{issueGroup.unassigned.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-pretty">No unassigned issues.</div>
|
||||
)}
|
||||
{issueGroup.unassigned.length > 0 && (
|
||||
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
|
||||
{issueGroup.unassigned.map((issue) => (
|
||||
<IssueLine
|
||||
key={issue.Issue.id}
|
||||
issue={issue}
|
||||
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} border-l text-xs text-muted-foreground`,
|
||||
)}
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<IssueModal
|
||||
issueData={issue}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
`flex items-center gap-${BREATHING_ROOM} text-xs text-muted-foreground`,
|
||||
"hover:text-foreground cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<StatusTag status={issue.Issue.status} colour={statusColour} className="text-[10px]" />
|
||||
<span className="tabular-nums">#{issue.Issue.number.toString().padStart(3, "0")}</span>
|
||||
<span className="truncate">{issue.Issue.title}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<IssueModal
|
||||
issueData={issue}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
`flex items-center gap-${BREATHING_ROOM} text-xs text-muted-foreground`,
|
||||
"hover:text-foreground cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<StatusTag status={issue.Issue.status} colour={statusColour} className="text-[10px]" />
|
||||
<span className="tabular-nums">#{issue.Issue.number.toString().padStart(3, "0")}</span>
|
||||
<span className="truncate">{issue.Issue.title}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user