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 { useMemo } from "react"; |
| #2 | import { motion } from "framer-motion"; |
| #3 | import { |
| #4 | TrendingUp, |
| #5 | CheckCircle2, |
| #6 | Clock, |
| #7 | AlertTriangle, |
| #8 | Layers, |
| #9 | CalendarDays, |
| #10 | } from "lucide-react"; |
| #11 | import { useStore } from "../store"; |
| #12 | import { ProgressBar } from "./ui"; |
| #13 | |
| #14 | export function AnalyticsView() { |
| #15 | const { issues, sprints, currentProject, users } = useStore(); |
| #16 | |
| #17 | const projectIssues = useMemo( |
| #18 | () => issues.filter((i) => i.projectId === currentProject?.id), |
| #19 | [issues, currentProject] |
| #20 | ); |
| #21 | |
| #22 | const stats = useMemo(() => { |
| #23 | const total = projectIssues.length; |
| #24 | const done = projectIssues.filter((i) => i.status === "done").length; |
| #25 | const inProgress = projectIssues.filter( |
| #26 | (i) => i.status === "in-progress" |
| #27 | ).length; |
| #28 | const inReview = projectIssues.filter( |
| #29 | (i) => i.status === "in-review" |
| #30 | ).length; |
| #31 | const overdue = projectIssues.filter( |
| #32 | (i) => |
| #33 | i.dueDate && |
| #34 | new Date(i.dueDate) < new Date() && |
| #35 | i.status !== "done" && |
| #36 | i.status !== "cancelled" |
| #37 | ).length; |
| #38 | |
| #39 | const byPriority = { |
| #40 | urgent: projectIssues.filter((i) => i.priority === "urgent").length, |
| #41 | high: projectIssues.filter((i) => i.priority === "high").length, |
| #42 | medium: projectIssues.filter((i) => i.priority === "medium").length, |
| #43 | low: projectIssues.filter((i) => i.priority === "low").length, |
| #44 | }; |
| #45 | |
| #46 | const byType = { |
| #47 | task: projectIssues.filter((i) => i.type === "task").length, |
| #48 | bug: projectIssues.filter((i) => i.type === "bug").length, |
| #49 | feature: projectIssues.filter((i) => i.type === "feature").length, |
| #50 | }; |
| #51 | |
| #52 | const byAssignee = users |
| #53 | .map((u) => ({ |
| #54 | user: u, |
| #55 | count: projectIssues.filter((i) => i.assigneeId === u.id).length, |
| #56 | done: projectIssues.filter( |
| #57 | (i) => i.assigneeId === u.id && i.status === "done" |
| #58 | ).length, |
| #59 | })) |
| #60 | .filter((a) => a.count > 0) |
| #61 | .sort((a, b) => b.count - a.count); |
| #62 | |
| #63 | const velocity = [3, 5, 4, 7, 6, 8, 5, 9, 7, 11, 8, 12]; |
| #64 | |
| #65 | return { |
| #66 | total, |
| #67 | done, |
| #68 | inProgress, |
| #69 | inReview, |
| #70 | overdue, |
| #71 | byPriority, |
| #72 | byType, |
| #73 | byAssignee, |
| #74 | velocity, |
| #75 | }; |
| #76 | }, [projectIssues, users]); |
| #77 | |
| #78 | const activeSprint = sprints.find( |
| #79 | (s) => s.projectId === currentProject?.id && s.status === "active" |
| #80 | ); |
| #81 | const sprintIssues = activeSprint |
| #82 | ? projectIssues.filter((i) => i.sprintId === activeSprint.id) |
| #83 | : []; |
| #84 | const sprintDone = sprintIssues.filter((i) => i.status === "done").length; |
| #85 | |
| #86 | const container = { |
| #87 | hidden: { opacity: 0 }, |
| #88 | show: { opacity: 1, transition: { staggerChildren: 0.05 } }, |
| #89 | }; |
| #90 | const item = { |
| #91 | hidden: { opacity: 0, y: 10 }, |
| #92 | show: { opacity: 1, y: 0 }, |
| #93 | }; |
| #94 | |
| #95 | return ( |
| #96 | <motion.div |
| #97 | variants={container} |
| #98 | initial="hidden" |
| #99 | animate="show" |
| #100 | className="p-6 max-w-5xl mx-auto space-y-6" |
| #101 | > |
| #102 | <motion.div variants={item}> |
| #103 | <h1 className="page-title">Analytics</h1> |
| #104 | <p className="text-sm text-zinc-500 mt-1"> |
| #105 | {currentProject?.name} project overview |
| #106 | </p> |
| #107 | </motion.div> |
| #108 | |
| #109 | {/* Summary Cards */} |
| #110 | <motion.div |
| #111 | variants={item} |
| #112 | className="grid grid-cols-2 lg:grid-cols-5 gap-3" |
| #113 | > |
| #114 | <StatCard |
| #115 | label="Total" |
| #116 | value={stats.total} |
| #117 | icon={Layers} |
| #118 | color="text-accent" |
| #119 | bg="bg-accent/10" |
| #120 | /> |
| #121 | <StatCard |
| #122 | label="Completed" |
| #123 | value={stats.done} |
| #124 | icon={CheckCircle2} |
| #125 | color="text-emerald-400" |
| #126 | bg="bg-emerald-500/10" |
| #127 | /> |
| #128 | <StatCard |
| #129 | label="In Progress" |
| #130 | value={stats.inProgress} |
| #131 | icon={Clock} |
| #132 | color="text-amber-400" |
| #133 | bg="bg-amber-500/10" |
| #134 | /> |
| #135 | <StatCard |
| #136 | label="In Review" |
| #137 | value={stats.inReview} |
| #138 | icon={Clock} |
| #139 | color="text-purple-400" |
| #140 | bg="bg-purple-500/10" |
| #141 | /> |
| #142 | <StatCard |
| #143 | label="Overdue" |
| #144 | value={stats.overdue} |
| #145 | icon={AlertTriangle} |
| #146 | color="text-red-400" |
| #147 | bg="bg-red-500/10" |
| #148 | /> |
| #149 | </motion.div> |
| #150 | |
| #151 | <div className="grid lg:grid-cols-2 gap-6"> |
| #152 | {/* Sprint Progress */} |
| #153 | <motion.div variants={item} className="card"> |
| #154 | <div className="flex items-center gap-2 mb-4"> |
| #155 | <CalendarDays size={16} className="text-accent" /> |
| #156 | <h2 className="text-sm font-semibold text-zinc-200"> |
| #157 | Sprint Progress |
| #158 | </h2> |
| #159 | </div> |
| #160 | {activeSprint ? ( |
| #161 | <div> |
| #162 | <div className="flex items-center justify-between mb-2"> |
| #163 | <span className="text-sm text-zinc-300"> |
| #164 | {activeSprint.name} |
| #165 | </span> |
| #166 | <span className="text-2xs text-zinc-500"> |
| #167 | {sprintDone}/{sprintIssues.length} |
| #168 | </span> |
| #169 | </div> |
| #170 | <ProgressBar value={sprintDone} max={sprintIssues.length} /> |
| #171 | <div className="grid grid-cols-3 gap-3 mt-4"> |
| #172 | <div className="text-center"> |
| #173 | <p className="text-lg font-semibold text-zinc-200"> |
| #174 | {sprintIssues.length - sprintDone} |
| #175 | </p> |
| #176 | <p className="text-2xs text-zinc-500">Remaining</p> |
| #177 | </div> |
| #178 | <div className="text-center"> |
| #179 | <p className="text-lg font-semibold text-emerald-400"> |
| #180 | {sprintDone} |
| #181 | </p> |
| #182 | <p className="text-2xs text-zinc-500">Completed</p> |
| #183 | </div> |
| #184 | <div className="text-center"> |
| #185 | <p className="text-lg font-semibold text-zinc-200"> |
| #186 | {sprintIssues.length > 0 |
| #187 | ? Math.round((sprintDone / sprintIssues.length) * 100) |
| #188 | : 0} |
| #189 | % |
| #190 | </p> |
| #191 | <p className="text-2xs text-zinc-500">Progress</p> |
| #192 | </div> |
| #193 | </div> |
| #194 | </div> |
| #195 | ) : ( |
| #196 | <p className="text-sm text-zinc-500 text-center py-4"> |
| #197 | No active sprint |
| #198 | </p> |
| #199 | )} |
| #200 | </motion.div> |
| #201 | |
| #202 | {/* Velocity Chart */} |
| #203 | <motion.div variants={item} className="card"> |
| #204 | <div className="flex items-center gap-2 mb-4"> |
| #205 | <TrendingUp size={16} className="text-emerald-400" /> |
| #206 | <h2 className="text-sm font-semibold text-zinc-200"> |
| #207 | Velocity |
| #208 | </h2> |
| #209 | </div> |
| #210 | <div className="flex items-end gap-1 h-32"> |
| #211 | {stats.velocity.map((v, i) => ( |
| #212 | <motion.div |
| #213 | key={i} |
| #214 | initial={{ height: 0 }} |
| #215 | animate={{ height: `${(v / 12) * 100}%` }} |
| #216 | transition={{ delay: i * 0.05, duration: 0.4 }} |
| #217 | className="flex-1 bg-accent/20 rounded-t-sm hover:bg-accent/40 transition-colors relative group" |
| #218 | > |
| #219 | <div className="absolute -top-6 left-1/2 -translate-x-1/2 text-2xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity"> |
| #220 | {v} |
| #221 | </div> |
| #222 | </motion.div> |
| #223 | ))} |
| #224 | </div> |
| #225 | <div className="flex justify-between mt-2"> |
| #226 | <span className="text-2xs text-zinc-600">12 weeks ago</span> |
| #227 | <span className="text-2xs text-zinc-600">This week</span> |
| #228 | </div> |
| #229 | </motion.div> |
| #230 | |
| #231 | {/* By Priority */} |
| #232 | <motion.div variants={item} className="card"> |
| #233 | <h2 className="text-sm font-semibold text-zinc-200 mb-4"> |
| #234 | By Priority |
| #235 | </h2> |
| #236 | <div className="space-y-3"> |
| #237 | <PriorityBar label="Urgent" value={stats.byPriority.urgent} max={stats.total} color="bg-red-400" /> |
| #238 | <PriorityBar label="High" value={stats.byPriority.high} max={stats.total} color="bg-orange-400" /> |
| #239 | <PriorityBar label="Medium" value={stats.byPriority.medium} max={stats.total} color="bg-yellow-400" /> |
| #240 | <PriorityBar label="Low" value={stats.byPriority.low} max={stats.total} color="bg-blue-400" /> |
| #241 | </div> |
| #242 | </motion.div> |
| #243 | |
| #244 | {/* By Type */} |
| #245 | <motion.div variants={item} className="card"> |
| #246 | <h2 className="text-sm font-semibold text-zinc-200 mb-4"> |
| #247 | By Type |
| #248 | </h2> |
| #249 | <div className="space-y-3"> |
| #250 | <PriorityBar label="Tasks" value={stats.byType.task} max={stats.total} color="bg-zinc-400" /> |
| #251 | <PriorityBar label="Bugs" value={stats.byType.bug} max={stats.total} color="bg-red-400" /> |
| #252 | <PriorityBar label="Features" value={stats.byType.feature} max={stats.total} color="bg-indigo-400" /> |
| #253 | </div> |
| #254 | </motion.div> |
| #255 | </div> |
| #256 | |
| #257 | {/* Team */} |
| #258 | <motion.div variants={item} className="card"> |
| #259 | <h2 className="text-sm font-semibold text-zinc-200 mb-4"> |
| #260 | Team Load |
| #261 | </h2> |
| #262 | <div className="space-y-3"> |
| #263 | {stats.byAssignee.map(({ user, count, done }) => ( |
| #264 | <div key={user.id} className="flex items-center gap-4"> |
| #265 | <div className="w-8 h-8 rounded-full bg-surface-3 flex items-center justify-center text-2xs font-medium text-zinc-300"> |
| #266 | {user.name |
| #267 | .split(" ") |
| #268 | .map((n) => n[0]) |
| #269 | .join("")} |
| #270 | </div> |
| #271 | <div className="flex-1 min-w-0"> |
| #272 | <div className="flex items-center justify-between mb-1"> |
| #273 | <span className="text-sm text-zinc-300">{user.name}</span> |
| #274 | <span className="text-2xs text-zinc-500"> |
| #275 | {done}/{count} done |
| #276 | </span> |
| #277 | </div> |
| #278 | <ProgressBar value={done} max={count} size="sm" /> |
| #279 | </div> |
| #280 | </div> |
| #281 | ))} |
| #282 | </div> |
| #283 | </motion.div> |
| #284 | </motion.div> |
| #285 | ); |
| #286 | } |
| #287 | |
| #288 | function StatCard({ |
| #289 | label, |
| #290 | value, |
| #291 | icon: Icon, |
| #292 | color, |
| #293 | bg, |
| #294 | }: { |
| #295 | label: string; |
| #296 | value: number; |
| #297 | icon: React.ComponentType<{ size?: number; className?: string }>; |
| #298 | color: string; |
| #299 | bg: string; |
| #300 | }) { |
| #301 | return ( |
| #302 | <div className="card"> |
| #303 | <div className="flex items-center justify-between mb-2"> |
| #304 | <span className="text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #305 | {label} |
| #306 | </span> |
| #307 | <div className={`p-1.5 rounded-lg ${bg}`}> |
| #308 | <Icon size={14} className={color} /> |
| #309 | </div> |
| #310 | </div> |
| #311 | <p className="text-2xl font-semibold text-zinc-100">{value}</p> |
| #312 | </div> |
| #313 | ); |
| #314 | } |
| #315 | |
| #316 | function PriorityBar({ |
| #317 | label, |
| #318 | value, |
| #319 | max, |
| #320 | color, |
| #321 | }: { |
| #322 | label: string; |
| #323 | value: number; |
| #324 | max: number; |
| #325 | color: string; |
| #326 | }) { |
| #327 | return ( |
| #328 | <div className="flex items-center gap-3"> |
| #329 | <span className="text-sm text-zinc-400 w-20">{label}</span> |
| #330 | <div className="flex-1"> |
| #331 | <div className="h-2 rounded-full bg-surface-3 overflow-hidden"> |
| #332 | <motion.div |
| #333 | initial={{ width: 0 }} |
| #334 | animate={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }} |
| #335 | transition={{ duration: 0.5 }} |
| #336 | className={`h-full rounded-full ${color}`} |
| #337 | /> |
| #338 | </div> |
| #339 | </div> |
| #340 | <span className="text-2xs text-zinc-500 w-8 text-right">{value}</span> |
| #341 | </div> |
| #342 | ); |
| #343 | } |
| #344 |