From 2fc17fb6dce3ef484a8c87c3c8dff5d52c03d972 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Mon, 26 Jan 2026 22:27:30 +0000 Subject: [PATCH 1/4] Create value-proposition.typ --- value-proposition.typ | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 value-proposition.typ diff --git a/value-proposition.typ b/value-proposition.typ new file mode 100644 index 0000000..c913acf --- /dev/null +++ b/value-proposition.typ @@ -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 From a9adef742370115a3104be689e631eabd10edc0a Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Mon, 26 Jan 2026 23:21:40 +0000 Subject: [PATCH 2/4] added canClear --- packages/frontend/src/components/ui/input.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/frontend/src/components/ui/input.tsx b/packages/frontend/src/components/ui/input.tsx index 20a6190..36e7faf 100644 --- a/packages/frontend/src/components/ui/input.tsx +++ b/packages/frontend/src/components/ui/input.tsx @@ -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(null); + const isSearch = type === "search"; + const canClear = isSearch && typeof props.value === "string" && props.value.length > 0; return (
)} + {canClear && ( + + )} {showCounter && currentLength !== undefined && maxLength !== undefined && ( Date: Mon, 26 Jan 2026 23:21:57 +0000 Subject: [PATCH 3/4] issue filters --- .../frontend/src/components/issues-table.tsx | 108 +++++- packages/frontend/src/pages/Issues.tsx | 349 +++++++++++++++++- 2 files changed, 451 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index 3dd57bb..bd8c4a7 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -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 "#"; diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index 4d95b85..ba487a9 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -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(() => 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 = (items: T[], id: number | null | undefined, getId: (item: T) => number) => id == null ? null : (items.find((item) => getId(item) === id) ?? null); const selectFallback = (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; + 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 = { + 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 (
+ {selectedOrganisationId && selectedProjectId && ( +
+
+ { + const nextQuery = event.target.value; + handleIssueFiltersChange((current) => ({ + ...current, + query: nextQuery, + })); + }} + placeholder="Search issues" + showCounter={false} + /> +
+ {selectedOrganisation?.Organisation.features.issueStatus && ( + + + Status + + + Status + + {Object.keys(statuses).length === 0 && ( + No statuses + )} + {Object.entries(statuses).map(([status, colour]) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + statuses: checked + ? Array.from(new Set([...current.statuses, status])) + : current.statuses.filter((item) => item !== status), + })); + }} + > + + + ))} + + + )} + {selectedOrganisation?.Organisation.features.issueTypes && ( + + + Type + + + Type + + {Object.keys(issueTypes).length === 0 && ( + No types + )} + {Object.entries(issueTypes).map(([type, definition]) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + types: checked + ? Array.from(new Set([...current.types, type])) + : current.types.filter((item) => item !== type), + })); + }} + > +
+ + {type} +
+
+ ))} +
+
+ )} + + + Assignee + + + Assignee + + { + handleIssueFiltersChange((current) => ({ + ...current, + assignees: checked + ? Array.from(new Set([...current.assignees, "unassigned"])) + : current.assignees.filter((item) => item !== "unassigned"), + })); + }} + > + Unassigned + + {members.length === 0 && No members} + {members.map((member) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + assignees: checked + ? Array.from(new Set([...current.assignees, String(member.id)])) + : current.assignees.filter((item) => item !== String(member.id)), + })); + }} + > + + + ))} + + + {selectedOrganisation?.Organisation.features.sprints && ( + + + {sprintLabel} + + + Sprint + + { + handleIssueFiltersChange((current) => ({ + ...current, + sprintId: + value === "all" ? "all" : value === "none" ? "none" : Number.parseInt(value, 10), + })); + }} + > + All sprints + No sprint + {sprintsData.map((sprint) => ( + + {sprint.name} + + ))} + + + + )} + + + Sort: {sortLabels[issueFilters.sort]} + + + Sort + + { + handleIssueFiltersChange((current) => ({ + ...current, + sort: value as IssuesTableFilters["sort"], + })); + }} + > + Newest + Oldest + Title A-Z + Title Z-A + Status + + + + { + handleIssueFiltersChange({ ...defaultIssuesTableFilters }); + }} + > + + +
+ )} + {selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
- +
From 71be765956cde10cb967ee357b33e40e154b3db6 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Mon, 26 Jan 2026 23:27:26 +0000 Subject: [PATCH 4/4] small-screen-overlay --- packages/frontend/src/App.css | 24 ++++++++++++++++++++++++ packages/frontend/src/main.tsx | 3 +++ todo.md | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index baece8d..19f940f 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -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; + } +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 7a2d4be..f214187 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -64,5 +64,8 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + sprint will look very ugly and disjointed if you try to use it at a resolution this small! + , ); diff --git a/todo.md b/todo.md index b7da5e1..2adb817 100644 --- a/todo.md +++ b/todo.md @@ -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)