repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Projectflow
stars
latest
clone command
git clone gitlawb://did:key:z6Mkfh4Y...QBEi/projectflowgit clone gitlawb://did:key:z6Mkfh4Y.../projectflowb3cded1async from playground23h ago| #1 | import { useState, useMemo } from "react"; |
| #2 | import { motion, AnimatePresence } from "framer-motion"; |
| #3 | import { |
| #4 | Plus, |
| #5 | CalendarDays, |
| #6 | Play, |
| #7 | CheckCircle2, |
| #8 | MoreHorizontal, |
| #9 | Trash2, |
| #10 | ChevronDown, |
| #11 | ChevronRight, |
| #12 | ArrowUpRight, |
| #13 | } from "lucide-react"; |
| #14 | import clsx from "clsx"; |
| #15 | import { format } from "date-fns"; |
| #16 | import { useStore } from "../store"; |
| #17 | import type { Sprint, SprintStatus } from "../types"; |
| #18 | import { |
| #19 | Modal, |
| #20 | ProgressBar, |
| #21 | EmptyState, |
| #22 | Avatar, |
| #23 | Dropdown, |
| #24 | DropdownItem, |
| #25 | IssueTypeBadge, |
| #26 | } from "./ui"; |
| #27 | |
| #28 | export function SprintsView() { |
| #29 | const { |
| #30 | sprints, |
| #31 | issues, |
| #32 | currentProject, |
| #33 | users, |
| #34 | startSprint, |
| #35 | completeSprint, |
| #36 | createSprint, |
| #37 | deleteSprint, |
| #38 | removeIssueFromSprint, |
| #39 | setCurrentIssue, |
| #40 | } = useStore(); |
| #41 | |
| #42 | const [showCreate, setShowCreate] = useState(false); |
| #43 | const [expandedSprint, setExpandedSprint] = useState<string | null>(null); |
| #44 | const [newName, setNewName] = useState(""); |
| #45 | const [newDesc, setNewDesc] = useState(""); |
| #46 | const [newStart, setNewStart] = useState(""); |
| #47 | const [newEnd, setNewEnd] = useState(""); |
| #48 | |
| #49 | const projectSprints = useMemo( |
| #50 | () => |
| #51 | sprints |
| #52 | .filter((s) => s.projectId === currentProject?.id) |
| #53 | .sort((a, b) => { |
| #54 | const order: Record<SprintStatus, number> = { |
| #55 | active: 0, |
| #56 | planned: 1, |
| #57 | completed: 2, |
| #58 | }; |
| #59 | return order[a.status] - order[b.status]; |
| #60 | }), |
| #61 | [sprints, currentProject] |
| #62 | ); |
| #63 | |
| #64 | const handleCreate = () => { |
| #65 | if (!newName.trim() || !currentProject) return; |
| #66 | createSprint({ |
| #67 | projectId: currentProject.id, |
| #68 | name: newName, |
| #69 | description: newDesc, |
| #70 | startDate: newStart |
| #71 | ? new Date(newStart).toISOString() |
| #72 | : new Date().toISOString(), |
| #73 | endDate: newEnd |
| #74 | ? new Date(newEnd).toISOString() |
| #75 | : new Date(Date.now() + 14 * 86400000).toISOString(), |
| #76 | status: "planned", |
| #77 | }); |
| #78 | setNewName(""); |
| #79 | setNewDesc(""); |
| #80 | setNewStart(""); |
| #81 | setNewEnd(""); |
| #82 | setShowCreate(false); |
| #83 | }; |
| #84 | |
| #85 | const getSprintStats = (sprint: Sprint) => { |
| #86 | const sprintIssues = issues.filter((i) => i.sprintId === sprint.id); |
| #87 | const completed = sprintIssues.filter((i) => i.status === "done").length; |
| #88 | return { total: sprintIssues.length, completed }; |
| #89 | }; |
| #90 | |
| #91 | return ( |
| #92 | <div className="p-6 max-w-5xl mx-auto"> |
| #93 | <div className="page-header"> |
| #94 | <h1 className="page-title">Sprints</h1> |
| #95 | <button onClick={() => setShowCreate(true)} className="btn-primary"> |
| #96 | <Plus size={16} /> |
| #97 | New Sprint |
| #98 | </button> |
| #99 | </div> |
| #100 | |
| #101 | {projectSprints.length === 0 ? ( |
| #102 | <EmptyState |
| #103 | icon={<CalendarDays size={24} />} |
| #104 | title="No sprints yet" |
| #105 | description="Create your first sprint to start planning" |
| #106 | action={ |
| #107 | <button |
| #108 | onClick={() => setShowCreate(true)} |
| #109 | className="btn-primary" |
| #110 | > |
| #111 | <Plus size={16} /> |
| #112 | Create Sprint |
| #113 | </button> |
| #114 | } |
| #115 | /> |
| #116 | ) : ( |
| #117 | <div className="space-y-3"> |
| #118 | {projectSprints.map((sprint) => { |
| #119 | const stats = getSprintStats(sprint); |
| #120 | const sprintIssues = issues.filter( |
| #121 | (i) => i.sprintId === sprint.id |
| #122 | ); |
| #123 | const isExpanded = expandedSprint === sprint.id; |
| #124 | |
| #125 | return ( |
| #126 | <motion.div |
| #127 | key={sprint.id} |
| #128 | layout |
| #129 | className="card overflow-hidden" |
| #130 | > |
| #131 | {/* Sprint header */} |
| #132 | <div |
| #133 | className="flex items-center justify-between cursor-pointer" |
| #134 | onClick={() => |
| #135 | setExpandedSprint(isExpanded ? null : sprint.id) |
| #136 | } |
| #137 | > |
| #138 | <div className="flex items-center gap-3"> |
| #139 | {isExpanded ? ( |
| #140 | <ChevronDown size={16} className="text-zinc-500" /> |
| #141 | ) : ( |
| #142 | <ChevronRight size={16} className="text-zinc-500" /> |
| #143 | )} |
| #144 | <div> |
| #145 | <div className="flex items-center gap-2"> |
| #146 | <h3 className="text-sm font-semibold text-zinc-200"> |
| #147 | {sprint.name} |
| #148 | </h3> |
| #149 | <SprintStatusBadge status={sprint.status} /> |
| #150 | </div> |
| #151 | <p className="text-2xs text-zinc-500 mt-0.5"> |
| #152 | {format(new Date(sprint.startDate), "MMM d")} –{" "} |
| #153 | {format(new Date(sprint.endDate), "MMM d, yyyy")} |
| #154 | </p> |
| #155 | </div> |
| #156 | </div> |
| #157 | |
| #158 | <div className="flex items-center gap-4"> |
| #159 | <div className="text-right"> |
| #160 | <span className="text-sm font-medium text-zinc-300"> |
| #161 | {stats.completed}/{stats.total} |
| #162 | </span> |
| #163 | <span className="text-2xs text-zinc-500 ml-1"> |
| #164 | issues |
| #165 | </span> |
| #166 | </div> |
| #167 | <div className="w-24"> |
| #168 | <ProgressBar |
| #169 | value={stats.completed} |
| #170 | max={stats.total} |
| #171 | size="sm" |
| #172 | /> |
| #173 | </div> |
| #174 | <Dropdown |
| #175 | trigger={ |
| #176 | <button |
| #177 | onClick={(e) => e.stopPropagation()} |
| #178 | className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-surface-3" |
| #179 | > |
| #180 | <MoreHorizontal size={14} /> |
| #181 | </button> |
| #182 | } |
| #183 | > |
| #184 | {sprint.status === "planned" && ( |
| #185 | <DropdownItem |
| #186 | onClick={() => startSprint(sprint.id)} |
| #187 | > |
| #188 | <Play size={14} /> |
| #189 | Start Sprint |
| #190 | </DropdownItem> |
| #191 | )} |
| #192 | {sprint.status === "active" && ( |
| #193 | <DropdownItem |
| #194 | onClick={() => completeSprint(sprint.id)} |
| #195 | > |
| #196 | <CheckCircle2 size={14} /> |
| #197 | Complete Sprint |
| #198 | </DropdownItem> |
| #199 | )} |
| #200 | <DropdownItem |
| #201 | danger |
| #202 | onClick={() => { |
| #203 | if (confirm("Delete this sprint?")) { |
| #204 | deleteSprint(sprint.id); |
| #205 | } |
| #206 | }} |
| #207 | > |
| #208 | <Trash2 size={14} /> |
| #209 | Delete Sprint |
| #210 | </DropdownItem> |
| #211 | </Dropdown> |
| #212 | </div> |
| #213 | </div> |
| #214 | |
| #215 | {/* Sprint description */} |
| #216 | {sprint.description && ( |
| #217 | <p className="text-sm text-zinc-500 mt-2 ml-7"> |
| #218 | {sprint.description} |
| #219 | </p> |
| #220 | )} |
| #221 | |
| #222 | {/* Expanded issues */} |
| #223 | <AnimatePresence> |
| #224 | {isExpanded && ( |
| #225 | <motion.div |
| #226 | initial={{ height: 0, opacity: 0 }} |
| #227 | animate={{ height: "auto", opacity: 1 }} |
| #228 | exit={{ height: 0, opacity: 0 }} |
| #229 | transition={{ duration: 0.2 }} |
| #230 | className="overflow-hidden" |
| #231 | > |
| #232 | <div className="mt-4 ml-7 space-y-1"> |
| #233 | {sprintIssues.length === 0 ? ( |
| #234 | <p className="text-sm text-zinc-600 py-4"> |
| #235 | No issues in this sprint. Add issues from the |
| #236 | backlog. |
| #237 | </p> |
| #238 | ) : ( |
| #239 | sprintIssues.map((issue) => { |
| #240 | const assignee = issue.assigneeId |
| #241 | ? users.find( |
| #242 | (u) => u.id === issue.assigneeId |
| #243 | ) |
| #244 | : null; |
| #245 | return ( |
| #246 | <div |
| #247 | key={issue.id} |
| #248 | className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-surface-3 transition-colors group" |
| #249 | > |
| #250 | <div |
| #251 | className="flex items-center gap-3 min-w-0 cursor-pointer flex-1" |
| #252 | onClick={() => setCurrentIssue(issue)} |
| #253 | > |
| #254 | <IssueTypeBadge type={issue.type} /> |
| #255 | <span className="text-2xs font-mono text-zinc-500 shrink-0"> |
| #256 | {issue.identifier} |
| #257 | </span> |
| #258 | <span className="text-sm text-zinc-300 truncate"> |
| #259 | {issue.title} |
| #260 | </span> |
| #261 | </div> |
| #262 | <div className="flex items-center gap-2 shrink-0"> |
| #263 | {assignee && ( |
| #264 | <Avatar |
| #265 | name={assignee.name} |
| #266 | size="xs" |
| #267 | /> |
| #268 | )} |
| #269 | <button |
| #270 | onClick={() => |
| #271 | removeIssueFromSprint(issue.id) |
| #272 | } |
| #273 | className="p-1 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all" |
| #274 | title="Remove from sprint" |
| #275 | > |
| #276 | <Trash2 size={12} /> |
| #277 | </button> |
| #278 | </div> |
| #279 | </div> |
| #280 | ); |
| #281 | }) |
| #282 | )} |
| #283 | </div> |
| #284 | </motion.div> |
| #285 | )} |
| #286 | </AnimatePresence> |
| #287 | </motion.div> |
| #288 | ); |
| #289 | })} |
| #290 | </div> |
| #291 | )} |
| #292 | |
| #293 | {/* Create Sprint Modal */} |
| #294 | <Modal |
| #295 | open={showCreate} |
| #296 | onClose={() => setShowCreate(false)} |
| #297 | title="Create Sprint" |
| #298 | > |
| #299 | <div className="space-y-4"> |
| #300 | <div> |
| #301 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #302 | Name |
| #303 | </label> |
| #304 | <input |
| #305 | value={newName} |
| #306 | onChange={(e) => setNewName(e.target.value)} |
| #307 | placeholder="Sprint 4" |
| #308 | className="input" |
| #309 | autoFocus |
| #310 | /> |
| #311 | </div> |
| #312 | |
| #313 | <div> |
| #314 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #315 | Description |
| #316 | </label> |
| #317 | <textarea |
| #318 | value={newDesc} |
| #319 | onChange={(e) => setNewDesc(e.target.value)} |
| #320 | placeholder="What's the goal of this sprint?" |
| #321 | className="textarea" |
| #322 | rows={2} |
| #323 | /> |
| #324 | </div> |
| #325 | |
| #326 | <div className="grid grid-cols-2 gap-3"> |
| #327 | <div> |
| #328 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #329 | Start Date |
| #330 | </label> |
| #331 | <input |
| #332 | type="date" |
| #333 | value={newStart} |
| #334 | onChange={(e) => setNewStart(e.target.value)} |
| #335 | className="input" |
| #336 | /> |
| #337 | </div> |
| #338 | <div> |
| #339 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #340 | End Date |
| #341 | </label> |
| #342 | <input |
| #343 | type="date" |
| #344 | value={newEnd} |
| #345 | onChange={(e) => setNewEnd(e.target.value)} |
| #346 | className="input" |
| #347 | /> |
| #348 | </div> |
| #349 | </div> |
| #350 | |
| #351 | <div className="flex justify-end gap-2 pt-2"> |
| #352 | <button |
| #353 | onClick={() => setShowCreate(false)} |
| #354 | className="btn-secondary" |
| #355 | > |
| #356 | Cancel |
| #357 | </button> |
| #358 | <button |
| #359 | onClick={handleCreate} |
| #360 | disabled={!newName.trim()} |
| #361 | className="btn-primary" |
| #362 | > |
| #363 | Create Sprint |
| #364 | </button> |
| #365 | </div> |
| #366 | </div> |
| #367 | </Modal> |
| #368 | </div> |
| #369 | ); |
| #370 | } |
| #371 | |
| #372 | function SprintStatusBadge({ status }: { status: SprintStatus }) { |
| #373 | const map: Record<SprintStatus, { label: string; cls: string }> = { |
| #374 | planned: { label: "Planned", cls: "bg-zinc-500/10 text-zinc-400" }, |
| #375 | active: { label: "Active", cls: "bg-emerald-500/10 text-emerald-400" }, |
| #376 | completed: { |
| #377 | label: "Completed", |
| #378 | cls: "bg-blue-500/10 text-blue-400", |
| #379 | }, |
| #380 | }; |
| #381 | |
| #382 | const { label, cls } = map[status]; |
| #383 | return <span className={clsx("badge text-2xs", cls)}>{label}</span>; |
| #384 | } |
| #385 |