Gowrisankar Cursor commited on
Commit
b00cff7
·
1 Parent(s): bcf0657

Add Hours/Days effort toggle and simplify project timeline row.

Browse files

Let planners switch allocation labels and totals between hours and days, and keep schedule dates in the drawer instead of drawing a span line on the project row.

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/components/projects/AllocationDragTimeline.tsx CHANGED
@@ -5,9 +5,11 @@ import {
5
  buildAllocationBarSegment,
6
  dateStringsFromDayIndices,
7
  dayIndexFromPointer,
8
- formatHoursPerDay,
9
  projectBarStyle,
10
  roundToHalfHour,
 
 
11
  } from "../../planner/allocationTimeline";
12
  import type { Allocation, Person } from "../../types";
13
  import { AllocationCreatePopover, type AllocationDragDraft } from "./AllocationCreatePopover";
@@ -25,6 +27,7 @@ interface AllocationDragTimelineProps {
25
  onEditAllocation?: () => void;
26
  onSave: (draft: AllocationDragDraft) => void;
27
  isSaving?: boolean;
 
28
  }
29
 
30
  export function AllocationDragTimeline({
@@ -38,6 +41,7 @@ export function AllocationDragTimeline({
38
  onEditAllocation,
39
  onSave,
40
  isSaving = false,
 
41
  }: AllocationDragTimelineProps) {
42
  const trackRef = useRef<HTMLDivElement>(null);
43
  const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
@@ -142,7 +146,7 @@ export function AllocationDragTimeline({
142
  }}
143
  />
144
  ) : null}
145
- {existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined ? (
146
  <button
147
  className={existingSegment.className}
148
  onClick={(event) => {
@@ -155,11 +159,21 @@ export function AllocationDragTimeline({
155
  background: color,
156
  borderTopColor: color,
157
  }}
158
- title={formatHoursPerDay(allocationPct, weeklyCapacityHrs)}
 
 
 
 
 
159
  type="button"
160
  >
161
  <span className="planner-bar-label">
162
- {formatHoursPerDay(allocationPct, weeklyCapacityHrs)}
 
 
 
 
 
163
  </span>
164
  </button>
165
  ) : null}
 
5
  buildAllocationBarSegment,
6
  dateStringsFromDayIndices,
7
  dayIndexFromPointer,
8
+ formatAllocationEffort,
9
  projectBarStyle,
10
  roundToHalfHour,
11
+ visibleWeekdayCount,
12
+ type EffortDisplayMode,
13
  } from "../../planner/allocationTimeline";
14
  import type { Allocation, Person } from "../../types";
15
  import { AllocationCreatePopover, type AllocationDragDraft } from "./AllocationCreatePopover";
 
27
  onEditAllocation?: () => void;
28
  onSave: (draft: AllocationDragDraft) => void;
29
  isSaving?: boolean;
30
+ effortMode?: EffortDisplayMode;
31
  }
32
 
33
  export function AllocationDragTimeline({
 
41
  onEditAllocation,
42
  onSave,
43
  isSaving = false,
44
+ effortMode = "hours",
45
  }: AllocationDragTimelineProps) {
46
  const trackRef = useRef<HTMLDivElement>(null);
47
  const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
 
146
  }}
147
  />
148
  ) : null}
149
+ {existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined && allocation ? (
150
  <button
151
  className={existingSegment.className}
152
  onClick={(event) => {
 
159
  background: color,
160
  borderTopColor: color,
161
  }}
162
+ title={formatAllocationEffort(
163
+ effortMode,
164
+ allocationPct,
165
+ weeklyCapacityHrs,
166
+ visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date),
167
+ )}
168
  type="button"
169
  >
170
  <span className="planner-bar-label">
171
+ {formatAllocationEffort(
172
+ effortMode,
173
+ allocationPct,
174
+ weeklyCapacityHrs,
175
+ visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date),
176
+ )}
177
  </span>
178
  </button>
179
  ) : null}
frontend/src/components/projects/EffortDisplaySelect.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EffortDisplayMode } from "../../planner/allocationTimeline";
2
+
3
+ interface EffortDisplaySelectProps {
4
+ value: EffortDisplayMode;
5
+ onChange: (value: EffortDisplayMode) => void;
6
+ }
7
+
8
+ const OPTIONS: Array<{ value: EffortDisplayMode; label: string }> = [
9
+ { value: "days", label: "Days" },
10
+ { value: "hours", label: "Hours" },
11
+ ];
12
+
13
+ export function EffortDisplaySelect({ value, onChange }: EffortDisplaySelectProps) {
14
+ return (
15
+ <select
16
+ aria-label="Display effort as hours or days"
17
+ className="effort-display-select"
18
+ onChange={(event) => onChange(event.target.value as EffortDisplayMode)}
19
+ value={value}
20
+ >
21
+ {OPTIONS.map((option) => (
22
+ <option key={option.value} value={option.value}>
23
+ {option.label}
24
+ </option>
25
+ ))}
26
+ </select>
27
+ );
28
+ }
frontend/src/components/projects/ProjectCalendarTrack.tsx CHANGED
@@ -23,6 +23,8 @@ interface ProjectCalendarTrackProps {
23
  project: Project;
24
  dayDates: Date[];
25
  showTentative: boolean;
 
 
26
  enableMilestones?: boolean;
27
  enablePhaseDrag?: boolean;
28
  onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
@@ -40,6 +42,7 @@ export function ProjectCalendarTrack({
40
  project,
41
  dayDates,
42
  showTentative,
 
43
  enableMilestones = false,
44
  enablePhaseDrag = false,
45
  onCreateMilestone,
@@ -190,7 +193,10 @@ export function ProjectCalendarTrack({
190
  </div>
191
  ))
192
  : null}
193
- {lineSegment && mode === "project-line" && (!project.is_tentative || showTentative) ? (
 
 
 
194
  <div
195
  className={`project-span-line${project.is_tentative ? " tentative" : ""}`}
196
  style={{
 
23
  project: Project;
24
  dayDates: Date[];
25
  showTentative: boolean;
26
+ /** When false, the row shows milestones only (schedule dates live in the project drawer). */
27
+ showScheduleSpan?: boolean;
28
  enableMilestones?: boolean;
29
  enablePhaseDrag?: boolean;
30
  onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
 
42
  project,
43
  dayDates,
44
  showTentative,
45
+ showScheduleSpan = false,
46
  enableMilestones = false,
47
  enablePhaseDrag = false,
48
  onCreateMilestone,
 
193
  </div>
194
  ))
195
  : null}
196
+ {showScheduleSpan &&
197
+ lineSegment &&
198
+ mode === "project-line" &&
199
+ (!project.is_tentative || showTentative) ? (
200
  <div
201
  className={`project-span-line${project.is_tentative ? " tentative" : ""}`}
202
  style={{
frontend/src/components/projects/ProjectPlannerBlock.tsx CHANGED
@@ -6,11 +6,15 @@ import { AllocationDragTimeline } from "./AllocationDragTimeline";
6
  import type { AllocationDragDraft } from "./AllocationCreatePopover";
7
  import { ProjectCalendarTrack } from "./ProjectCalendarTrack";
8
  import { ProjectRowMenu } from "./ProjectRowMenu";
 
9
  import {
 
10
  allocatedHoursInRange,
 
11
  formatHoursAmount,
12
  roundToHalfHour,
13
  visibleWeekdayCount,
 
14
  } from "../../planner/allocationTimeline";
15
  import type { Allocation, Person, Project } from "../../types";
16
 
@@ -43,6 +47,8 @@ interface ProjectPlannerBlockProps {
43
  onArchive: () => void;
44
  isSavingAllocation?: boolean;
45
  isSavingPlanner?: boolean;
 
 
46
  onRestore?: () => void;
47
  }
48
 
@@ -68,6 +74,8 @@ export function ProjectPlannerBlock({
68
  onArchive,
69
  isSavingAllocation = false,
70
  isSavingPlanner = false,
 
 
71
  onRestore,
72
  }: ProjectPlannerBlockProps) {
73
  const showPhasesMilestones = plannerView === "phases_milestones";
@@ -89,30 +97,51 @@ export function ProjectPlannerBlock({
89
  [dayDates, project.start_date, project.end_date],
90
  );
91
 
92
- const { allocatedHours, capacityHours, developerCount } = useMemo(() => {
93
- let allocated = 0;
 
94
  const seen = new Set<number>();
95
  for (const allocation of allocations) {
96
  const person = peopleById.get(allocation.person_id);
97
  if (!person) continue;
98
  seen.add(person.id);
99
  const days = visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date);
100
- allocated += allocatedHoursInRange(allocation.allocation_pct, person.weekly_capacity_hrs, days);
 
101
  }
102
- const capacity = Array.from(seen).reduce((sum, personId) => {
103
  const person = peopleById.get(personId);
104
  return sum + (person ? roundToHalfHour(person.weekly_capacity_hrs / 5) * weekdayCount : 0);
105
  }, 0);
106
- return { allocatedHours: allocated, capacityHours: capacity, developerCount: seen.size };
 
 
 
 
 
 
 
107
  }, [allocations, peopleById, dayDates, weekdayCount]);
108
 
109
- const hoursPct = capacityHours > 0 ? Math.min(100, (allocatedHours / capacityHours) * 100) : 0;
110
- const hoursLabel =
111
- capacityHours > 0
112
- ? `${formatHoursAmount(allocatedHours)} / ${formatHoursAmount(capacityHours)}`
113
- : developerCount > 0
114
- ? `${formatHoursAmount(allocatedHours)} allocated`
115
- : "No developers assigned";
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  const roleGroups = useMemo(() => {
118
  const map = new Map<string, { roleName: string; allocations: Allocation[] }>();
@@ -209,18 +238,13 @@ export function ProjectPlannerBlock({
209
  ) : null}
210
 
211
  <div className="planner-row planner-project-subrow planner-hours-row">
212
- <div className="planner-row-label nested">
213
- <span className="planner-sub-icon" aria-hidden>
214
-
215
- </span>
216
- <div className="person-meta">
217
- <strong>Hours</strong>
218
- <small>{hoursLabel}</small>
219
- </div>
220
  </div>
221
  <div className="planner-row-track">
222
  <div className="planner-hours-track">
223
- <div className="planner-hours-bar" style={{ width: `${hoursPct}%` }} />
224
  </div>
225
  </div>
226
  </div>
@@ -245,6 +269,7 @@ export function ProjectPlannerBlock({
245
  candidatePeople={candidatePeople}
246
  color={project.color}
247
  dayDates={dayDates}
 
248
  isSaving={isSavingAllocation}
249
  onSave={onCreateAllocation}
250
  />
@@ -270,6 +295,7 @@ export function ProjectPlannerBlock({
270
  allocationPct={allocation.allocation_pct}
271
  color={project.color}
272
  dayDates={dayDates}
 
273
  fixedPerson={person}
274
  isSaving={isSavingAllocation}
275
  onEditAllocation={() => onEditAllocation(allocation)}
@@ -294,6 +320,7 @@ export function ProjectPlannerBlock({
294
  candidatePeople={candidatePeople}
295
  color={project.color}
296
  dayDates={dayDates}
 
297
  isSaving={isSavingAllocation}
298
  onSave={onCreateAllocation}
299
  />
 
6
  import type { AllocationDragDraft } from "./AllocationCreatePopover";
7
  import { ProjectCalendarTrack } from "./ProjectCalendarTrack";
8
  import { ProjectRowMenu } from "./ProjectRowMenu";
9
+ import { EffortDisplaySelect } from "./EffortDisplaySelect";
10
  import {
11
+ allocatedDaysInRange,
12
  allocatedHoursInRange,
13
+ formatDaysAmount,
14
  formatHoursAmount,
15
  roundToHalfHour,
16
  visibleWeekdayCount,
17
+ type EffortDisplayMode,
18
  } from "../../planner/allocationTimeline";
19
  import type { Allocation, Person, Project } from "../../types";
20
 
 
47
  onArchive: () => void;
48
  isSavingAllocation?: boolean;
49
  isSavingPlanner?: boolean;
50
+ effortMode: EffortDisplayMode;
51
+ onEffortModeChange: (mode: EffortDisplayMode) => void;
52
  onRestore?: () => void;
53
  }
54
 
 
74
  onArchive,
75
  isSavingAllocation = false,
76
  isSavingPlanner = false,
77
+ effortMode,
78
+ onEffortModeChange,
79
  onRestore,
80
  }: ProjectPlannerBlockProps) {
81
  const showPhasesMilestones = plannerView === "phases_milestones";
 
97
  [dayDates, project.start_date, project.end_date],
98
  );
99
 
100
+ const { allocatedHours, capacityHours, allocatedDays, capacityDays, developerCount } = useMemo(() => {
101
+ let allocatedH = 0;
102
+ let allocatedD = 0;
103
  const seen = new Set<number>();
104
  for (const allocation of allocations) {
105
  const person = peopleById.get(allocation.person_id);
106
  if (!person) continue;
107
  seen.add(person.id);
108
  const days = visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date);
109
+ allocatedH += allocatedHoursInRange(allocation.allocation_pct, person.weekly_capacity_hrs, days);
110
+ allocatedD += allocatedDaysInRange(allocation.allocation_pct, days);
111
  }
112
+ const capacityH = Array.from(seen).reduce((sum, personId) => {
113
  const person = peopleById.get(personId);
114
  return sum + (person ? roundToHalfHour(person.weekly_capacity_hrs / 5) * weekdayCount : 0);
115
  }, 0);
116
+ const capacityD = seen.size * weekdayCount;
117
+ return {
118
+ allocatedHours: allocatedH,
119
+ capacityHours: capacityH,
120
+ allocatedDays: allocatedD,
121
+ capacityDays: capacityD,
122
+ developerCount: seen.size,
123
+ };
124
  }, [allocations, peopleById, dayDates, weekdayCount]);
125
 
126
+ const effortPct =
127
+ effortMode === "days"
128
+ ? capacityDays > 0
129
+ ? Math.min(100, (allocatedDays / capacityDays) * 100)
130
+ : 0
131
+ : capacityHours > 0
132
+ ? Math.min(100, (allocatedHours / capacityHours) * 100)
133
+ : 0;
134
+
135
+ const effortSummary =
136
+ developerCount === 0
137
+ ? "No developers assigned"
138
+ : effortMode === "days"
139
+ ? capacityDays > 0
140
+ ? `${formatDaysAmount(allocatedDays)} / ${formatDaysAmount(capacityDays)}`
141
+ : `${formatDaysAmount(allocatedDays)} allocated`
142
+ : capacityHours > 0
143
+ ? `${formatHoursAmount(allocatedHours)} / ${formatHoursAmount(capacityHours)}`
144
+ : `${formatHoursAmount(allocatedHours)} allocated`;
145
 
146
  const roleGroups = useMemo(() => {
147
  const map = new Map<string, { roleName: string; allocations: Allocation[] }>();
 
238
  ) : null}
239
 
240
  <div className="planner-row planner-project-subrow planner-hours-row">
241
+ <div className="planner-row-label nested planner-effort-label">
242
+ <EffortDisplaySelect onChange={onEffortModeChange} value={effortMode} />
243
+ <span className={`planner-effort-total${effortPct > 100 ? " is-over" : ""}`}>{effortSummary}</span>
 
 
 
 
 
244
  </div>
245
  <div className="planner-row-track">
246
  <div className="planner-hours-track">
247
+ <div className="planner-hours-bar" style={{ width: `${effortPct}%` }} />
248
  </div>
249
  </div>
250
  </div>
 
269
  candidatePeople={candidatePeople}
270
  color={project.color}
271
  dayDates={dayDates}
272
+ effortMode={effortMode}
273
  isSaving={isSavingAllocation}
274
  onSave={onCreateAllocation}
275
  />
 
295
  allocationPct={allocation.allocation_pct}
296
  color={project.color}
297
  dayDates={dayDates}
298
+ effortMode={effortMode}
299
  fixedPerson={person}
300
  isSaving={isSavingAllocation}
301
  onEditAllocation={() => onEditAllocation(allocation)}
 
320
  candidatePeople={candidatePeople}
321
  color={project.color}
322
  dayDates={dayDates}
323
+ effortMode={effortMode}
324
  isSaving={isSavingAllocation}
325
  onSave={onCreateAllocation}
326
  />
frontend/src/pages/Projects.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
  import { addWeeks, format, startOfWeek } from "date-fns";
3
- import { useCallback, useMemo, useState, type CSSProperties, type FormEvent } from "react";
4
 
5
  import { createAllocation } from "../api/allocations";
6
  import {
@@ -21,6 +21,7 @@ import { EmptyState } from "../components/ui/EmptyState";
21
  import { Modal } from "../components/ui/Modal";
22
  import { toastFromError, useToast } from "../components/ui/Toast";
23
  import { useAllocations, usePeople, useProjects, useTeams } from "../hooks/usePortalData";
 
24
  import { overlapsVisibleRange } from "../planner/projectTimeline";
25
  import { buildWeekdayRange, isToday, monthMarkersForDays } from "../planner/timeline";
26
  import type { Allocation, Milestone, Person, Project, ProjectInput } from "../types";
@@ -94,6 +95,14 @@ export function ProjectsPage() {
94
  const [memberModalProject, setMemberModalProject] = useState<Project | null>(null);
95
  const [editingAllocation, setEditingAllocation] = useState<Allocation | null>(null);
96
  const [plannerView, setPlannerView] = useState<PlannerViewMode>("phases_milestones");
 
 
 
 
 
 
 
 
97
 
98
  const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
99
  const startDate = anchor;
@@ -528,8 +537,10 @@ export function ProjectsPage() {
528
  <ProjectPlannerBlock
529
  allocations={projectAllocations}
530
  dayDates={plannerDays}
 
531
  expanded={expanded}
532
  isSavingAllocation={createAllocationMutation.isPending}
 
533
  isSavingPlanner={plannerSaveMutation.isPending}
534
  key={project.id}
535
  onAddPerson={() => {
 
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
  import { addWeeks, format, startOfWeek } from "date-fns";
3
+ import { useCallback, useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
4
 
5
  import { createAllocation } from "../api/allocations";
6
  import {
 
21
  import { Modal } from "../components/ui/Modal";
22
  import { toastFromError, useToast } from "../components/ui/Toast";
23
  import { useAllocations, usePeople, useProjects, useTeams } from "../hooks/usePortalData";
24
+ import type { EffortDisplayMode } from "../planner/allocationTimeline";
25
  import { overlapsVisibleRange } from "../planner/projectTimeline";
26
  import { buildWeekdayRange, isToday, monthMarkersForDays } from "../planner/timeline";
27
  import type { Allocation, Milestone, Person, Project, ProjectInput } from "../types";
 
95
  const [memberModalProject, setMemberModalProject] = useState<Project | null>(null);
96
  const [editingAllocation, setEditingAllocation] = useState<Allocation | null>(null);
97
  const [plannerView, setPlannerView] = useState<PlannerViewMode>("phases_milestones");
98
+ const [effortMode, setEffortMode] = useState<EffortDisplayMode>(() => {
99
+ const saved = localStorage.getItem("projects-effort-mode");
100
+ return saved === "days" ? "days" : "hours";
101
+ });
102
+
103
+ useEffect(() => {
104
+ localStorage.setItem("projects-effort-mode", effortMode);
105
+ }, [effortMode]);
106
 
107
  const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
108
  const startDate = anchor;
 
537
  <ProjectPlannerBlock
538
  allocations={projectAllocations}
539
  dayDates={plannerDays}
540
+ effortMode={effortMode}
541
  expanded={expanded}
542
  isSavingAllocation={createAllocationMutation.isPending}
543
+ onEffortModeChange={setEffortMode}
544
  isSavingPlanner={plannerSaveMutation.isPending}
545
  key={project.id}
546
  onAddPerson={() => {
frontend/src/planner/allocationTimeline.ts CHANGED
@@ -76,6 +76,30 @@ export function formatHoursPerDay(allocationPct: number, weeklyCapacityHrs: numb
76
  return `${formatHoursAmount(daily)}/day`;
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  export function visibleWeekdayCount(dayDates: Date[], startDate: string, endDate: string): number {
80
  return dayDates.filter((day) => {
81
  const key = format(day, "yyyy-MM-dd");
 
76
  return `${formatHoursAmount(daily)}/day`;
77
  }
78
 
79
+ export type EffortDisplayMode = "hours" | "days";
80
+
81
+ export function formatDaysAmount(days: number): string {
82
+ return `${Math.round(days)}d`;
83
+ }
84
+
85
+ export function allocatedDaysInRange(allocationPct: number, weekdayCount: number): number {
86
+ if (weekdayCount <= 0) return 0;
87
+ return Math.round((weekdayCount * allocationPct) / 100);
88
+ }
89
+
90
+ export function formatAllocationEffort(
91
+ mode: EffortDisplayMode,
92
+ allocationPct: number,
93
+ weeklyCapacityHrs: number,
94
+ weekdayCount: number,
95
+ ): string {
96
+ if (mode === "days") {
97
+ const days = allocatedDaysInRange(allocationPct, weekdayCount);
98
+ return days > 0 ? formatDaysAmount(days) : "0d";
99
+ }
100
+ return formatHoursPerDay(allocationPct, weeklyCapacityHrs);
101
+ }
102
+
103
  export function visibleWeekdayCount(dayDates: Date[], startDate: string, endDate: string): number {
104
  return dayDates.filter((day) => {
105
  const key = format(day, "yyyy-MM-dd");
frontend/src/styles.css CHANGED
@@ -2202,6 +2202,36 @@ th {
2202
  width: 18px;
2203
  }
2204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2205
  .planner-hours-track {
2206
  align-self: center;
2207
  background: #ece9f5;
 
2202
  width: 18px;
2203
  }
2204
 
2205
+ .planner-effort-label {
2206
+ align-items: center;
2207
+ display: flex;
2208
+ flex-wrap: wrap;
2209
+ gap: 8px 10px;
2210
+ }
2211
+
2212
+ .effort-display-select {
2213
+ background: #fff;
2214
+ border: 2px solid #2563eb;
2215
+ border-radius: 8px;
2216
+ color: #1e40af;
2217
+ cursor: pointer;
2218
+ font-size: 13px;
2219
+ font-weight: 700;
2220
+ min-width: 72px;
2221
+ padding: 4px 8px;
2222
+ }
2223
+
2224
+ .planner-effort-total {
2225
+ color: var(--muted);
2226
+ font-size: 12px;
2227
+ font-weight: 700;
2228
+ margin-left: auto;
2229
+ }
2230
+
2231
+ .planner-effort-total.is-over {
2232
+ color: #dc2626;
2233
+ }
2234
+
2235
  .planner-hours-track {
2236
  align-self: center;
2237
  background: #ece9f5;