mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
@@ -224,3 +224,27 @@
|
|||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.small-screen-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 559px) {
|
||||||
|
.small-screen-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
padding-top: calc(env(safe-area-inset-top, 0px) + 24px);
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { useSelection } from "@/components/selection-provider";
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import StatusTag from "@/components/status-tag";
|
import StatusTag from "@/components/status-tag";
|
||||||
@@ -7,12 +7,32 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
|
import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type IssuesTableFilters = {
|
||||||
|
query: string;
|
||||||
|
statuses: string[];
|
||||||
|
types: string[];
|
||||||
|
assignees: string[];
|
||||||
|
sprintId: "all" | "none" | number;
|
||||||
|
sort: "newest" | "oldest" | "title-asc" | "title-desc" | "status";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultIssuesTableFilters: IssuesTableFilters = {
|
||||||
|
query: "",
|
||||||
|
statuses: [],
|
||||||
|
types: [],
|
||||||
|
assignees: [],
|
||||||
|
sprintId: "all",
|
||||||
|
sort: "newest",
|
||||||
|
};
|
||||||
|
|
||||||
export function IssuesTable({
|
export function IssuesTable({
|
||||||
columns = {},
|
columns = {},
|
||||||
className,
|
className,
|
||||||
|
filters,
|
||||||
}: {
|
}: {
|
||||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||||
className: string;
|
className: string;
|
||||||
|
filters?: IssuesTableFilters;
|
||||||
}) {
|
}) {
|
||||||
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
||||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||||
@@ -24,7 +44,91 @@ export function IssuesTable({
|
|||||||
{ icon: string; color: string }
|
{ icon: string; color: string }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
|
const issues = useMemo(() => {
|
||||||
|
const query = filters?.query?.trim().toLowerCase() ?? "";
|
||||||
|
const queryIsNumber = query !== "" && /^[0-9]+$/.test(query);
|
||||||
|
const statusSet = new Set(filters?.statuses ?? []);
|
||||||
|
const typeSet = new Set(filters?.types ?? []);
|
||||||
|
const assigneeFilters = filters?.assignees ?? [];
|
||||||
|
const includeUnassigned = assigneeFilters.includes("unassigned");
|
||||||
|
const assigneeIds = new Set(
|
||||||
|
assigneeFilters
|
||||||
|
.filter((assignee) => assignee !== "unassigned")
|
||||||
|
.map((assignee) => Number.parseInt(assignee, 10))
|
||||||
|
.filter((assigneeId) => !Number.isNaN(assigneeId)),
|
||||||
|
);
|
||||||
|
const sprintFilter = filters?.sprintId ?? "all";
|
||||||
|
const sort = filters?.sort ?? "newest";
|
||||||
|
|
||||||
|
let next = [...issuesData];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
next = next.filter((issueData) => {
|
||||||
|
const title = issueData.Issue.title.toLowerCase();
|
||||||
|
const description = issueData.Issue.description.toLowerCase();
|
||||||
|
const matchesText = title.includes(query) || description.includes(query);
|
||||||
|
if (matchesText) return true;
|
||||||
|
if (queryIsNumber) {
|
||||||
|
return issueData.Issue.number.toString().includes(query);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusSet.size > 0) {
|
||||||
|
next = next.filter((issueData) => statusSet.has(issueData.Issue.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeSet.size > 0) {
|
||||||
|
next = next.filter((issueData) => typeSet.has(issueData.Issue.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeFilters.length > 0) {
|
||||||
|
next = next.filter((issueData) => {
|
||||||
|
const hasAssignees = issueData.Assignees && issueData.Assignees.length > 0;
|
||||||
|
const matchesAssigned =
|
||||||
|
hasAssignees && issueData.Assignees.some((assignee) => assigneeIds.has(assignee.id));
|
||||||
|
const matchesUnassigned = includeUnassigned && !hasAssignees;
|
||||||
|
return matchesAssigned || matchesUnassigned;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sprintFilter !== "all") {
|
||||||
|
if (sprintFilter === "none") {
|
||||||
|
next = next.filter((issueData) => issueData.Issue.sprintId == null);
|
||||||
|
} else {
|
||||||
|
next = next.filter((issueData) => issueData.Issue.sprintId === sprintFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sort) {
|
||||||
|
case "oldest":
|
||||||
|
next.sort((a, b) => a.Issue.number - b.Issue.number);
|
||||||
|
break;
|
||||||
|
case "title-asc":
|
||||||
|
next.sort((a, b) => a.Issue.title.localeCompare(b.Issue.title));
|
||||||
|
break;
|
||||||
|
case "title-desc":
|
||||||
|
next.sort((a, b) => b.Issue.title.localeCompare(a.Issue.title));
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
next.sort((a, b) => a.Issue.status.localeCompare(b.Issue.status));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
next.sort((a, b) => b.Issue.number - a.Issue.number);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}, [issuesData, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIssueId == null) return;
|
||||||
|
const isVisible = issues.some((issueData) => issueData.Issue.id === selectedIssueId);
|
||||||
|
if (!isVisible) {
|
||||||
|
selectIssue(null);
|
||||||
|
}
|
||||||
|
}, [issues, selectedIssueId, selectIssue]);
|
||||||
|
|
||||||
const getIssueUrl = (issueNumber: number) => {
|
const getIssueUrl = (issueNumber: number) => {
|
||||||
if (!selectedOrganisation || !selectedProject) return "#";
|
if (!selectedOrganisation || !selectedProject) return "#";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import { useRef } from "react";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ function Input({
|
|||||||
}) {
|
}) {
|
||||||
const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
|
const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
|
||||||
const currentLength = typeof props.value === "string" ? props.value.length : undefined;
|
const currentLength = typeof props.value === "string" ? props.value.length : undefined;
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const isSearch = type === "search";
|
||||||
|
const canClear = isSearch && typeof props.value === "string" && props.value.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -34,18 +38,40 @@ function Input({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||||
"h-full flex-1 min-w-0 bg-transparent px-3 py-1 pr-1 text-base outline-none",
|
"h-full flex-1 min-w-0 bg-transparent px-3 py-1 pr-1 text-base outline-none",
|
||||||
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||||
|
"appearance-none [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
|
||||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
showHashPrefix ? "pl-2 py-0" : "pl-3",
|
showHashPrefix ? "pl-2 py-0" : "pl-3",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
{canClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer px-2 h-full flex w-fit items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
const nativeInputValue = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
);
|
||||||
|
nativeInputValue?.set?.call(inputRef.current, "");
|
||||||
|
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showCounter && currentLength !== undefined && maxLength !== undefined && (
|
{showCounter && currentLength !== undefined && maxLength !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -64,5 +64,8 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<output className="small-screen-overlay" aria-live="polite">
|
||||||
|
sprint will look very ugly and disjointed if you try to use it at a resolution this small!
|
||||||
|
</output>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,94 @@
|
|||||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||||
import { IssueModal } from "@/components/issue-modal";
|
import { IssueModal } from "@/components/issue-modal";
|
||||||
import { IssuesTable } from "@/components/issues-table";
|
import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table";
|
||||||
import { useSelection } from "@/components/selection-provider";
|
import { useSelection } from "@/components/selection-provider";
|
||||||
|
import SmallUserDisplay from "@/components/small-user-display";
|
||||||
|
import StatusTag from "@/components/status-tag";
|
||||||
import TopBar from "@/components/top-bar";
|
import TopBar from "@/components/top-bar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import Icon, { type IconName } from "@/components/ui/icon";
|
||||||
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
||||||
import { BREATHING_ROOM } from "@/lib/layout";
|
import { BREATHING_ROOM } from "@/lib/layout";
|
||||||
import { useIssues, useOrganisations, useProjects, useSelectedIssue } from "@/lib/query/hooks";
|
import {
|
||||||
|
useIssues,
|
||||||
|
useOrganisationMembers,
|
||||||
|
useOrganisations,
|
||||||
|
useProjects,
|
||||||
|
useSelectedIssue,
|
||||||
|
useSelectedOrganisation,
|
||||||
|
useSprints,
|
||||||
|
} from "@/lib/query/hooks";
|
||||||
|
|
||||||
|
const parseListParam = (value: string | null) =>
|
||||||
|
value
|
||||||
|
? value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const parseIssueFilters = (search: string): IssuesTableFilters => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const query = params.get("q")?.trim() ?? "";
|
||||||
|
const statuses = parseListParam(params.get("status"));
|
||||||
|
const types = parseListParam(params.get("type"));
|
||||||
|
const assignees = parseListParam(params.get("assignee"));
|
||||||
|
const sprintParam = params.get("sprint")?.trim().toLowerCase() ?? "";
|
||||||
|
const sortParam = params.get("sort")?.trim().toLowerCase() ?? "";
|
||||||
|
|
||||||
|
let sprintId: IssuesTableFilters["sprintId"] = "all";
|
||||||
|
if (sprintParam === "none") {
|
||||||
|
sprintId = "none";
|
||||||
|
} else if (sprintParam !== "") {
|
||||||
|
const parsedSprintId = Number.parseInt(sprintParam, 10);
|
||||||
|
sprintId = Number.isNaN(parsedSprintId) ? "all" : parsedSprintId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortValues: IssuesTableFilters["sort"][] = ["newest", "oldest", "title-asc", "title-desc", "status"];
|
||||||
|
const sort = sortValues.includes(sortParam as IssuesTableFilters["sort"])
|
||||||
|
? (sortParam as IssuesTableFilters["sort"])
|
||||||
|
: "newest";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultIssuesTableFilters,
|
||||||
|
query,
|
||||||
|
statuses,
|
||||||
|
types,
|
||||||
|
assignees,
|
||||||
|
sprintId,
|
||||||
|
sort,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtersEqual = (left: IssuesTableFilters, right: IssuesTableFilters) => {
|
||||||
|
if (left.query !== right.query) return false;
|
||||||
|
if (left.sprintId !== right.sprintId) return false;
|
||||||
|
if (left.sort !== right.sort) return false;
|
||||||
|
if (left.statuses.length !== right.statuses.length) return false;
|
||||||
|
if (left.types.length !== right.types.length) return false;
|
||||||
|
if (left.assignees.length !== right.assignees.length) return false;
|
||||||
|
return (
|
||||||
|
left.statuses.every((status) => right.statuses.includes(status)) &&
|
||||||
|
left.types.every((type) => right.types.includes(type)) &&
|
||||||
|
left.assignees.every((assignee) => right.assignees.includes(assignee))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Issues() {
|
export default function Issues() {
|
||||||
const {
|
const {
|
||||||
@@ -43,6 +122,11 @@ export default function Issues() {
|
|||||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||||
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
||||||
const selectedIssue = useSelectedIssue();
|
const selectedIssue = useSelectedIssue();
|
||||||
|
const selectedOrganisation = useSelectedOrganisation();
|
||||||
|
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
||||||
|
const { data: sprintsData = [] } = useSprints(selectedProjectId);
|
||||||
|
const parsedFilters = useMemo(() => parseIssueFilters(location.search), [location.search]);
|
||||||
|
const [issueFilters, setIssueFilters] = useState<IssuesTableFilters>(() => parsedFilters);
|
||||||
|
|
||||||
const organisations = useMemo(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
@@ -53,6 +137,40 @@ export default function Issues() {
|
|||||||
[projectsData],
|
[projectsData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIssueFilters((current) => (filtersEqual(current, parsedFilters) ? current : parsedFilters));
|
||||||
|
}, [parsedFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentParams = new URLSearchParams(location.search);
|
||||||
|
const nextParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
if (issueFilters.query) nextParams.set("q", issueFilters.query);
|
||||||
|
else nextParams.delete("q");
|
||||||
|
|
||||||
|
if (issueFilters.statuses.length > 0) nextParams.set("status", issueFilters.statuses.join(","));
|
||||||
|
else nextParams.delete("status");
|
||||||
|
|
||||||
|
if (issueFilters.types.length > 0) nextParams.set("type", issueFilters.types.join(","));
|
||||||
|
else nextParams.delete("type");
|
||||||
|
|
||||||
|
if (issueFilters.assignees.length > 0) nextParams.set("assignee", issueFilters.assignees.join(","));
|
||||||
|
else nextParams.delete("assignee");
|
||||||
|
|
||||||
|
if (issueFilters.sprintId === "none") nextParams.set("sprint", "none");
|
||||||
|
else if (issueFilters.sprintId !== "all") nextParams.set("sprint", String(issueFilters.sprintId));
|
||||||
|
else nextParams.delete("sprint");
|
||||||
|
|
||||||
|
if (issueFilters.sort !== defaultIssuesTableFilters.sort) nextParams.set("sort", issueFilters.sort);
|
||||||
|
else nextParams.delete("sort");
|
||||||
|
|
||||||
|
if (currentParams.toString() === nextParams.toString()) return;
|
||||||
|
|
||||||
|
const search = nextParams.toString();
|
||||||
|
const nextUrl = `${location.pathname}${search ? `?${search}` : ""}`;
|
||||||
|
window.history.replaceState(null, "", nextUrl);
|
||||||
|
}, [issueFilters, location.pathname, location.search]);
|
||||||
|
|
||||||
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
||||||
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
||||||
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
||||||
@@ -162,15 +280,238 @@ export default function Issues() {
|
|||||||
flow.stage = "done";
|
flow.stage = "done";
|
||||||
}, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]);
|
}, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]);
|
||||||
|
|
||||||
|
const handleIssueFiltersChange = (
|
||||||
|
next: IssuesTableFilters | ((current: IssuesTableFilters) => IssuesTableFilters),
|
||||||
|
) => {
|
||||||
|
setIssueFilters((current) => (typeof next === "function" ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = (selectedOrganisation?.Organisation.statuses ?? {}) as Record<string, string>;
|
||||||
|
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
{ icon: IconName; color: string }
|
||||||
|
>;
|
||||||
|
const members = useMemo(
|
||||||
|
() => [...membersData].map((member) => member.User).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[membersData],
|
||||||
|
);
|
||||||
|
const sortLabels: Record<IssuesTableFilters["sort"], string> = {
|
||||||
|
newest: "Newest",
|
||||||
|
oldest: "Oldest",
|
||||||
|
"title-asc": "Title A-Z",
|
||||||
|
"title-desc": "Title Z-A",
|
||||||
|
status: "Status",
|
||||||
|
};
|
||||||
|
const sprintLabel = useMemo(() => {
|
||||||
|
if (issueFilters.sprintId === "all") return "Sprint";
|
||||||
|
if (issueFilters.sprintId === "none") return "No sprint";
|
||||||
|
const sprintMatch = sprintsData.find((sprint) => sprint.id === issueFilters.sprintId);
|
||||||
|
return sprintMatch?.name ?? "Sprint";
|
||||||
|
}, [issueFilters.sprintId, sprintsData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
|
||||||
|
{selectedOrganisationId && selectedProjectId && (
|
||||||
|
<div className={`flex flex-wrap gap-${BREATHING_ROOM} items-center`}>
|
||||||
|
<div className="w-64">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={issueFilters.query}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextQuery = event.target.value;
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
query: nextQuery,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Search issues"
|
||||||
|
showCounter={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedOrganisation?.Organisation.features.issueStatus && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger size="default" className="h-9">
|
||||||
|
Status
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{Object.keys(statuses).length === 0 && (
|
||||||
|
<DropdownMenuItem disabled>No statuses</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{Object.entries(statuses).map(([status, colour]) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={status}
|
||||||
|
checked={issueFilters.statuses.includes(status)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
statuses: checked
|
||||||
|
? Array.from(new Set([...current.statuses, status]))
|
||||||
|
: current.statuses.filter((item) => item !== status),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusTag status={status} colour={colour} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{selectedOrganisation?.Organisation.features.issueTypes && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger size="default" className="h-9">
|
||||||
|
Type
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Type</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{Object.keys(issueTypes).length === 0 && (
|
||||||
|
<DropdownMenuItem disabled>No types</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{Object.entries(issueTypes).map(([type, definition]) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={type}
|
||||||
|
checked={issueFilters.types.includes(type)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
types: checked
|
||||||
|
? Array.from(new Set([...current.types, type]))
|
||||||
|
: current.types.filter((item) => item !== type),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon={definition.icon} size={14} color={definition.color} />
|
||||||
|
<span>{type}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger size="default" className="h-9">
|
||||||
|
Assignee
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Assignee</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={issueFilters.assignees.includes("unassigned")}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
assignees: checked
|
||||||
|
? Array.from(new Set([...current.assignees, "unassigned"]))
|
||||||
|
: current.assignees.filter((item) => item !== "unassigned"),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unassigned
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
{members.length === 0 && <DropdownMenuItem disabled>No members</DropdownMenuItem>}
|
||||||
|
{members.map((member) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={member.id}
|
||||||
|
checked={issueFilters.assignees.includes(String(member.id))}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
assignees: checked
|
||||||
|
? Array.from(new Set([...current.assignees, String(member.id)]))
|
||||||
|
: current.assignees.filter((item) => item !== String(member.id)),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SmallUserDisplay user={member} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{selectedOrganisation?.Organisation.features.sprints && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger size="default" className="h-9">
|
||||||
|
{sprintLabel}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Sprint</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={String(issueFilters.sprintId)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
sprintId:
|
||||||
|
value === "all" ? "all" : value === "none" ? "none" : Number.parseInt(value, 10),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="all">All sprints</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="none">No sprint</DropdownMenuRadioItem>
|
||||||
|
{sprintsData.map((sprint) => (
|
||||||
|
<DropdownMenuRadioItem key={sprint.id} value={String(sprint.id)}>
|
||||||
|
{sprint.name}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger size="default" className="h-9">
|
||||||
|
Sort: {sortLabels[issueFilters.sort]}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Sort</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={issueFilters.sort}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
handleIssueFiltersChange((current) => ({
|
||||||
|
...current,
|
||||||
|
sort: value as IssuesTableFilters["sort"],
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="newest">Newest</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="oldest">Oldest</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="title-asc">Title A-Z</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="title-desc">Title Z-A</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="status">Status</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<IconButton
|
||||||
|
variant="outline"
|
||||||
|
className="w-9 h-9"
|
||||||
|
disabled={
|
||||||
|
!issueFilters.query &&
|
||||||
|
issueFilters.statuses.length === 0 &&
|
||||||
|
issueFilters.types.length === 0 &&
|
||||||
|
issueFilters.assignees.length === 0 &&
|
||||||
|
issueFilters.sprintId === defaultIssuesTableFilters.sprintId &&
|
||||||
|
issueFilters.sort === defaultIssuesTableFilters.sort
|
||||||
|
}
|
||||||
|
aria-label="Clear filters"
|
||||||
|
title="Clear filters"
|
||||||
|
onClick={() => {
|
||||||
|
handleIssueFiltersChange({ ...defaultIssuesTableFilters });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="undo" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
||||||
<ResizablePanelGroup className={`flex-1`}>
|
<ResizablePanelGroup className={`flex-1`}>
|
||||||
<ResizablePanel id={"left"} minSize={400}>
|
<ResizablePanel id={"left"} minSize={400}>
|
||||||
<div className="border w-full flex-shrink">
|
<div className="border w-full flex-shrink">
|
||||||
<IssuesTable columns={{ description: false }} className="w-full" />
|
<IssuesTable columns={{ description: false }} className="w-full" filters={issueFilters} />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
3
todo.md
3
todo.md
@@ -1,7 +1,8 @@
|
|||||||
# HIGH PRIORITY
|
# HIGH PRIORITY
|
||||||
|
|
||||||
|
- BUGS:
|
||||||
|
- issue descriptions not showing
|
||||||
- FEATURES:
|
- FEATURES:
|
||||||
- filters
|
|
||||||
- pricing page
|
- pricing page
|
||||||
- see jira and other competitors
|
- see jira and other competitors
|
||||||
- explore payment providers (stripe is the only one i know)
|
- explore payment providers (stripe is the only one i know)
|
||||||
|
|||||||
39
value-proposition.typ
Normal file
39
value-proposition.typ
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#set page(margin: (top: 32pt, bottom: 36pt, left: 40pt, right: 40pt))
|
||||||
|
#set text(font: "IBM Plex Sans", size: 11pt)
|
||||||
|
|
||||||
|
= Sprint
|
||||||
|
== What is the value proposition?
|
||||||
|
|
||||||
|
Sprint is a fast, developer-first project management tool for indie teams who are tired of bloated, sluggish systems. It keeps the core workflow focused on issues, sprints, and time tracking while staying flexible enough to match how small teams actually work.
|
||||||
|
|
||||||
|
=== Who is this for?
|
||||||
|
- Indie developer teams of 2 to 10 people
|
||||||
|
- Teams that value speed, clarity, and control over customization bloat
|
||||||
|
- Teams that want self-hosting and ownership of their data
|
||||||
|
|
||||||
|
=== What problem does it solve?
|
||||||
|
- Traditional tools like Jira are slow, complex, and priced for enterprise
|
||||||
|
- Developer workflows are forced to fit non-technical assumptions
|
||||||
|
- Small teams need focus, not configuration overhead
|
||||||
|
|
||||||
|
=== What is the promise?
|
||||||
|
Sprint delivers the essential project workflow with a fast UI, flexible org settings, and developer-friendly integrations. It is intentionally small, configurable, and self-hostable.
|
||||||
|
|
||||||
|
=== Why is it different?
|
||||||
|
- Speed first: quick navigation and minimal UI friction
|
||||||
|
- Developer-first: designed around dev workflows and terminology
|
||||||
|
- Flexible by default: customizable statuses and lightweight sprints
|
||||||
|
|
||||||
|
=== What proves it?
|
||||||
|
- Organisation and project management with role-based access
|
||||||
|
- Issue creation, assignment, and status tracking
|
||||||
|
- Time tracking with start, pause, and resume timers
|
||||||
|
- Sprint planning with date ranges
|
||||||
|
- Web and desktop access via Tauri
|
||||||
|
- Self-hostable deployment for full control
|
||||||
|
|
||||||
|
=== How is it built for performance and ownership?
|
||||||
|
- React + TypeScript frontend with Tailwind and shadcn/ui
|
||||||
|
- Bun API server with Drizzle ORM and PostgreSQL
|
||||||
|
- Shared schema package for consistent types across stack
|
||||||
|
- JWT authentication with CSRF protection
|
||||||
Reference in New Issue
Block a user