diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac7fbf..52152cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) --- +## [Unreleased] - 2026-06-29 — Edit Project modal identity strip (session 1) + +### Changed +- Edit Project modal stripped to identity-only fields: Title, Objective, Duration, Color, Status +- Weekly Outcome field removed from modal (field retained on type for session-2 migration) +- Links section removed from modal; moved to detail Sheet as inline-editable block +- Date pickers: native `` → shadcn `Calendar + Popover`, format `DD MMM YYYY`, end-date-after-start validation +- Color picker: hex input + native color input → row of 8 preset swatches with ring-on-active selection +- Status: `Select` dropdown → `ToggleGroup` segmented control (Active / Paused / Parked / Completed) +- Modal footer: sticky `Delete project` (muted red text) · `Cancel` · `Save`; Save disabled when pristine or invalid; Escape closes; Cmd/Ctrl+Enter saves + +### Added +- ADR-007 in `docs/DECISIONS_LOG.md`: project data model decision (milestones-only, sprint as separate object) +- Links block on detail Sheet: `+ Add`, inline edit, inline delete confirm, URL tooltip on hover + +--- + ## [Unreleased] - 2026-06-26 — Schedule block editor improvements ### Added diff --git a/components/modules/projects-module.tsx b/components/modules/projects-module.tsx index 2dcbf60..24fbc0c 100644 --- a/components/modules/projects-module.tsx +++ b/components/modules/projects-module.tsx @@ -9,15 +9,19 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Calendar } from "@/components/ui/calendar" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Progress } from "@/components/ui/progress" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Sheet, SheetContent, SheetClose, SheetTitle } from "@/components/ui/sheet" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { TruncatedTooltip } from "@/components/ui/truncated-tooltip" -import { AlertTriangle, Check, ChevronRight, ExternalLink, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react" +import { AlertTriangle, CalendarIcon, Check, ChevronRight, ExternalLink, Link as LinkIcon, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react" import type { Project, ProjectMilestone, ProjectStatus, Task } from "@/lib/types" import { ProjectsTimelineChart } from "./projects-timeline-chart" @@ -30,6 +34,17 @@ const LEGACY_COLOR_MAP: Record = { "bg-chart-5": "#a855f7", } +const PRESET_COLORS = [ + { name: "blue", value: "#3b82f6" }, + { name: "purple", value: "#8b5cf6" }, + { name: "yellow", value: "#eab308" }, + { name: "green", value: "#22c55e" }, + { name: "pink", value: "#ec4899" }, + { name: "orange", value: "#f97316" }, + { name: "red", value: "#ef4444" }, + { name: "slate", value: "#64748b" }, +] + const STATUS_SECTIONS: Array<{ id: ProjectStatus; label: string }> = [ { id: "active", label: "Active" }, { id: "paused", label: "Paused" }, @@ -117,16 +132,15 @@ export function ProjectsModule() { const [editingId, setEditingId] = useState(null) const [title, setTitle] = useState("") const [objective, setObjective] = useState("") - const [weeklyOutcome, setWeeklyOutcome] = useState("") const [status, setStatus] = useState("active") const [color, setColor] = useState(DEFAULT_PROJECT_COLOR) const [milestones, setMilestones] = useState("") const [startDate, setStartDate] = useState("") const [endDate, setEndDate] = useState("") - const [newLinkLabel, setNewLinkLabel] = useState("") - const [newLinkUrl, setNewLinkUrl] = useState("") - const [newLinks, setNewLinks] = useState>([]) const [formError, setFormError] = useState(null) + const [startDateOpen, setStartDateOpen] = useState(false) + const [endDateOpen, setEndDateOpen] = useState(false) + const [formSnapshot, setFormSnapshot] = useState<{ title: string; objective: string; status: string; startDate: string; endDate: string; color: string } | null>(null) // Module state const [view, setView] = useState<"list" | "timeline">("list") @@ -160,88 +174,60 @@ export function ProjectsModule() { setEditingId(null) setTitle("") setObjective("") - setWeeklyOutcome("") setStatus("active") setColor(DEFAULT_PROJECT_COLOR) setStartDate(defaultWeekRange.start) setEndDate(defaultWeekRange.end) setMilestones("") - setNewLinkLabel("") - setNewLinkUrl("") - setNewLinks([]) setFormError(null) + setFormSnapshot(null) setOpen(true) } function openEditDialog(project: Project) { + const s = getProjectStatus(project) + const c = normalizeProjectColor(project.color) setEditingId(project.id) setTitle(project.title) setObjective(project.objective) - setWeeklyOutcome(project.weeklyOutcome ?? "") - setStatus(getProjectStatus(project)) - setColor(normalizeProjectColor(project.color)) + setStatus(s) + setColor(c) setStartDate(project.weekStartISO) setEndDate(project.weekEndISO) setMilestones(project.milestones.map((m) => m.title).join(", ")) - setNewLinkLabel("") - setNewLinkUrl("") - setNewLinks(getProjectLinks(project)) setFormError(null) + setFormSnapshot({ title: project.title, objective: project.objective, status: s, startDate: project.weekStartISO, endDate: project.weekEndISO, color: c }) setOpen(true) } - function addStagedLink() { - const url = normalizeUrl(newLinkUrl) - if (!url) return - const label = newLinkLabel.trim() || "Link" - setNewLinks((prev) => [...prev, { label, url }]) - setNewLinkLabel("") - setNewLinkUrl("") - } - function saveProject() { if (!title.trim()) return - const normalizedOutcome = weeklyOutcome.trim() - if (status === "active" && !normalizedOutcome) { - setFormError("Active projects must have exactly one weekly outcome.") - return - } - const draftUrl = normalizeUrl(newLinkUrl) - const draftLinks = draftUrl - ? [...newLinks, { label: newLinkLabel.trim() || "Link", url: draftUrl }] - : newLinks - const normalizedLinks = draftLinks - .map((link) => ({ label: link.label.trim() || "Link", url: normalizeUrl(link.url) })) - .filter((link) => Boolean(link.url)) const selectedColor = normalizeProjectColor(color) - const weekStartISO = startDate || defaultWeekRange.start const weekEndISO = endDate || defaultWeekRange.end + if (weekStartISO > weekEndISO) { + setFormError("End date must be on or after start date.") + return + } if (!editingId) { addProject({ title: title.trim(), objective: objective.trim() || "Project objective", showOnTimeline: true, - weeklyOutcome: normalizedOutcome || undefined, status: status ?? "active", weekStartISO, weekEndISO, color: selectedColor, - url: normalizedLinks[0]?.url, - links: normalizedLinks.length > 0 ? normalizedLinks : undefined, milestones: parseMilestones(milestones), }) } else { updateProject(editingId, { title: title.trim(), objective: objective.trim() || "Project objective", - weeklyOutcome: normalizedOutcome || undefined, status: status ?? "active", weekStartISO, weekEndISO, color: selectedColor, - url: normalizedLinks[0]?.url, - links: normalizedLinks.length > 0 ? normalizedLinks : undefined, }) } setFormError(null) @@ -300,144 +286,164 @@ export function ProjectsModule() { New Project - - - {editingId ? "Edit Project" : "Create Project"} + { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault() + const isDirty = !formSnapshot || title !== formSnapshot.title || objective !== formSnapshot.objective || status !== formSnapshot.status || startDate !== formSnapshot.startDate || endDate !== formSnapshot.endDate || color !== formSnapshot.color + const isValid = !!title.trim() && (startDate <= endDate) + if (isValid && (!editingId || isDirty)) saveProject() + } + }} + > + + {editingId ? "Edit Project" : "Create Project"} -
+
- - setTitle(e.target.value)} /> + + setTitle(e.target.value)} className="mt-1.5" />
- - setObjective(e.target.value)} /> -
-
- - setWeeklyOutcome(e.target.value)} - placeholder="One concrete result for this week" - /> -

- Active projects require exactly one weekly outcome. -

+ + setObjective(e.target.value)} className="mt-1.5" />
- - + + { if (v) setStatus(v as Project["status"]) }} + className="mt-1.5 w-full" + > + {STATUS_SECTIONS.map((s) => ( + + {s.label} + + ))} +
- -
- setStartDate(e.target.value)} - className="flex-1 text-xs" - aria-label="Start date" - /> + +
+ + + + + + { + if (date) { setStartDate(format(date, "yyyy-MM-dd")); setStartDateOpen(false) } + }} + /> + + - setEndDate(e.target.value)} - className="flex-1 text-xs" - aria-label="End date" - /> -
-
-
- -
- setColor(e.target.value)} - className="h-10 w-14 cursor-pointer p-1" - aria-label="Pick project card color" - /> - setColor(e.target.value)} - placeholder="#3b82f6" - className="font-mono text-xs" - /> + + + + + + { + if (date) { setEndDate(format(date, "yyyy-MM-dd")); setEndDateOpen(false) } + }} + /> + +
+ {formError ? ( +

{formError}

+ ) : null}
- -
- setNewLinkLabel(e.target.value)} - placeholder="Label (e.g. Figma)" - className="sm:w-44 sm:flex-none" - /> - setNewLinkUrl(e.target.value)} - placeholder="https://..." - className="min-w-0" - /> - + +
+ {PRESET_COLORS.map((c) => { + const active = normalizeProjectColor(color) === c.value + return ( +
- {newLinks.length > 0 && ( -
- {newLinks.map((link, index) => ( -
- - {link.label}: {link.url} - - -
- ))} -
- )}
{!editingId ? (
- + setMilestones(e.target.value)} placeholder="Design, Build page, Deploy" + className="mt-1.5" />
) : null} - {formError ? ( -
- {formError} -
- ) : null} - +
+ {/* §4 — sticky footer */} +
+ {editingId ? ( + + + + + + + Delete "{title}"? + This cannot be undone. + + + Cancel + { deleteProject(editingId); setOpen(false) }} + > + Delete + + + + + ) : ( + + )} +
+ + +
@@ -511,6 +517,7 @@ export function ProjectsModule() { project={selectedProject} tasks={tasks} onEdit={openEditDialog} + onUpdateProject={updateProject} onStatusChange={(id, nextStatus) => updateProject(id, { status: nextStatus })} onDelete={deleteProject} editingMilestone={editingMilestone} @@ -685,6 +692,7 @@ function ProjectDetailPanel({ project, tasks, onEdit, + onUpdateProject, onStatusChange, onDelete, editingMilestone, @@ -703,6 +711,7 @@ function ProjectDetailPanel({ project: Project tasks: Task[] onEdit: (project: Project) => void + onUpdateProject: (id: string, updates: Partial) => void onStatusChange: (id: string, status: ProjectStatus) => void onDelete: (id: string) => void editingMilestone: { projectId: string; milestoneId: string } | null @@ -719,6 +728,13 @@ function ProjectDetailPanel({ onToggleTask: (taskId: string) => void }) { const [showAddMilestone, setShowAddMilestone] = useState(false) + const [showAddLink, setShowAddLink] = useState(false) + const [addLinkLabel, setAddLinkLabel] = useState("") + const [addLinkUrl, setAddLinkUrl] = useState("") + const [editingLinkIdx, setEditingLinkIdx] = useState(null) + const [editingLinkLabel, setEditingLinkLabel] = useState("") + const [editingLinkUrl, setEditingLinkUrl] = useState("") + const [deleteLinkIdx, setDeleteLinkIdx] = useState(null) const projectTasks = tasks.filter((t) => t.linkedProjectId === project.id) const completedTasks = projectTasks.filter((t) => t.completed).length @@ -735,13 +751,41 @@ function ProjectDetailPanel({ const weeklyLines = project.weeklyOutcome?.trim() ? project.weeklyOutcome.trim().split("\n").filter((l) => l.trim()) : [] - const links = getProjectLinks(project) function commitAddMilestone() { handleAddMilestone(project.id) setShowAddMilestone(false) } + const projectLinks = getProjectLinks(project) + + function commitAddLink() { + const url = normalizeUrl(addLinkUrl) + if (!url) return + const label = addLinkLabel.trim() || "Link" + const next = [...projectLinks, { label, url }] + onUpdateProject(project.id, { links: next, url: next[0]?.url }) + setAddLinkLabel("") + setAddLinkUrl("") + setShowAddLink(false) + } + + function commitEditLink(idx: number) { + const url = normalizeUrl(editingLinkUrl) + if (!url) return + const next = projectLinks.map((l, i) => + i === idx ? { label: editingLinkLabel.trim() || "Link", url } : l + ) + onUpdateProject(project.id, { links: next, url: next[0]?.url }) + setEditingLinkIdx(null) + } + + function commitDeleteLink(idx: number) { + const next = projectLinks.filter((_, i) => i !== idx) + onUpdateProject(project.id, { links: next.length > 0 ? next : undefined, url: next[0]?.url }) + setDeleteLinkIdx(null) + } + return (
{/* Header: ● title · status line · ⋯ · ✕ */} @@ -1037,26 +1081,155 @@ function ProjectDetailPanel({ ) })() : null} - {/* Links */} - {links.length > 0 ? ( -
+ {/* Links — inline editable */} +
+

Links

-
- {links.map((link, i) => ( - - - {link.label || "Link"} - - ))} + {!showAddLink ? ( + + ) : null} +
+ + {showAddLink ? ( +
+
+ setAddLinkLabel(e.target.value)} + placeholder="Label" + className="h-7 text-xs" + /> + setAddLinkUrl(e.target.value)} + placeholder="https://..." + className="h-7 flex-1 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") commitAddLink() + if (e.key === "Escape") setShowAddLink(false) + }} + autoFocus + /> +
+
+ + +
+ ) : null} + + {projectLinks.length === 0 && !showAddLink ? ( +

No links yet — add one.

+ ) : null} + +
+ {projectLinks.map((link, i) => ( +
+ {editingLinkIdx === i ? ( + <> + setEditingLinkLabel(e.target.value)} + className="h-6 w-24 shrink-0 text-xs" + /> + setEditingLinkUrl(e.target.value)} + className="h-6 min-w-0 flex-1 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") commitEditLink(i) + if (e.key === "Escape") setEditingLinkIdx(null) + }} + autoFocus + /> + + + + ) : ( + <> + + + + + {link.label || "Link"} + + + {link.url} + +
+ + {deleteLinkIdx === i ? ( +
+ + +
+ ) : ( + + )} +
+ + )} +
+ ))}
- ) : null} +
{/* Footer actions */} diff --git a/docs/DECISIONS_LOG.md b/docs/DECISIONS_LOG.md index 3fb64ce..ef9b739 100644 --- a/docs/DECISIONS_LOG.md +++ b/docs/DECISIONS_LOG.md @@ -111,3 +111,24 @@ Architecture Decision Records (ADR-style). Each entry explains a real choice mad - The WORKFLOW_AUTOMATION_PLAYBOOK.md is now the primary artifact — the app itself is the worked example. **Revisit trigger:** Never, unless the project changes hands or purpose. + +--- + +## ADR-007: Project data model — milestones only, sprint as separate object + +**Date:** 2026-06-29 +**Status:** Accepted + +**Context:** The detail Sheet shows both "tasks" and "milestones" counts; the Edit modal shows neither, only a free-text "Weekly Outcome" field. Vocabulary is split across surfaces, blocking any coherent UI for progress tracking. The Edit modal conflated three cadences (identity, lifecycle, weekly operations) in one form, making every weekly edit scroll past rarely-changed identity fields. + +**Decision:** +- Project hierarchy: **Project → Milestones** (no tasks layer). A Milestone is a binary done/not-done outcome with an optional target date. Type: `{ id, title, done, targetDate?, completedAt? }`. +- Sprint is a **separate object**, soft-linked from sprint outcomes to milestones via optional `linked_milestone?: string`. Sprint data model and persistence are deferred to session 2; this session does not introduce it. +- "Tasks" vocabulary is removed from all UI surfaces targeting the projects domain. Existing fields that conceptually represent tasks are either renamed to milestones or left untouched pending session 3 (the milestones UI session). + +**Consequences:** +- Session 1 (this session): removes Weekly Outcome from Edit modal, moves Links to the detail Sheet, strips Edit modal to identity-only fields (Title, Objective, Duration, Color, Status). +- Session 2: introduces Sprint type + persistence + Sheet block. +- Session 3: milestones UI overhaul + vocabulary cleanup across surfaces. + +**Revisit trigger:** Sprint data model introduced in session 2 conflicts with this structure, or session 3 vocabulary cleanup reveals that the `Project → Milestones` hierarchy is insufficient for the actual tracking workflow.