Merge pull request #1 from hex248/development

Development
This commit is contained in:
Oliver Bryan
2026-01-26 23:34:07 +00:00
committed by GitHub
7 changed files with 545 additions and 7 deletions

View File

@@ -224,3 +224,27 @@
align-items: center !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;
}
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import Avatar from "@/components/avatar";
import { useSelection } from "@/components/selection-provider";
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 { 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({
columns = {},
className,
filters,
}: {
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
className: string;
filters?: IssuesTableFilters;
}) {
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
const { data: issuesData = [] } = useIssues(selectedProjectId);
@@ -24,7 +44,91 @@ export function IssuesTable({
{ 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) => {
if (!selectedOrganisation || !selectedProject) return "#";

View File

@@ -1,4 +1,5 @@
import type * as React from "react";
import { useRef } from "react";
import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils";
@@ -16,6 +17,9 @@ function Input({
}) {
const maxLength = typeof props.maxLength === "number" ? props.maxLength : 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 (
<div
@@ -34,18 +38,40 @@ function Input({
</span>
)}
<input
ref={inputRef}
type={type}
data-slot="input"
className={cn(
"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",
"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",
showHashPrefix ? "pl-2 py-0" : "pl-3",
inputClassName,
)}
{...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 && (
<span
className={cn(

View File

@@ -64,5 +64,8 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
</SessionProvider>
</QueryProvider>
</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>,
);

View File

@@ -1,15 +1,94 @@
/** 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 { IssueDetailPane } from "@/components/issue-detail-pane";
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 SmallUserDisplay from "@/components/small-user-display";
import StatusTag from "@/components/status-tag";
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 { 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() {
const {
@@ -43,6 +122,11 @@ export default function Issues() {
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
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(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
@@ -53,6 +137,40 @@ export default function Issues() {
[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) =>
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
@@ -162,15 +280,238 @@ export default function Issues() {
flow.stage = "done";
}, [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 (
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
<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 && (
<ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}>
<div className="border w-full flex-shrink">
<IssuesTable columns={{ description: false }} className="w-full" />
<IssuesTable columns={{ description: false }} className="w-full" filters={issueFilters} />
</div>
</ResizablePanel>

View File

@@ -1,7 +1,8 @@
# HIGH PRIORITY
- BUGS:
- issue descriptions not showing
- FEATURES:
- filters
- pricing page
- see jira and other competitors
- explore payment providers (stripe is the only one i know)

39
value-proposition.typ Normal file
View 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