FN-5791: add markdown goal descriptions with show-more toggle

Improve goal card descriptions to support markdown formatting while keeping long content scannable.

- render goal descriptions with markdown + GFM support in GoalsView
- collapse long or multi-line descriptions by default and add Show more/Show less disclosure
- style collapsed description layout and add responsive clamp adjustments
- add tests for markdown rendering, long-description toggle behavior, and short-description no-toggle behavior

Files changed:
 packages/dashboard/app/components/GoalsView.css    | 27 +++++++++++++
 packages/dashboard/app/components/GoalsView.tsx    | 46 +++++++++++++++++++++-
 packages/dashboard/app/components/__tests__/GoalsView.test.tsx    | 42 ++++++++++++++++++++
 3 files changed, 114 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5791

Fusion-Task-Lineage: a3a0be5c-4a8d-4dc1-93a3-40891ab7762b
This commit is contained in:
gsxdsm
2026-05-31 16:43:58 -07:00
parent 7b70e7fb72
commit d585ac2181
3 changed files with 114 additions and 1 deletions

View File

@@ -112,6 +112,28 @@
color: var(--text-muted);
}
.goals-card-description > :first-child {
margin-top: 0;
}
.goals-card-description > :last-child {
margin-bottom: 0;
}
.goals-card-description-collapsed {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
max-height: calc(var(--space-md) * 6);
}
.goals-card-description-toggle {
margin-top: var(--space-xs);
align-self: flex-start;
color: var(--text-muted);
}
.goals-card-status {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
@@ -145,4 +167,9 @@
.goals-card-actions {
flex-direction: column;
}
.goals-card-description-collapsed {
-webkit-line-clamp: 3;
max-height: calc(var(--space-md) * 5);
}
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import type { Goal } from "@fusion/core";
import { Plus } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import "./GoalsView.css";
export interface GoalsViewProps {
@@ -11,6 +13,7 @@ const MAX_ACTIVE_GOALS = 5;
const WARNING_THRESHOLD = 3;
const CAP_ERROR_MESSAGE = "Cannot activate more than 5 goals. Resolve an active goal before activating another.";
const GOAL_DESCRIPTION_TOGGLE_LENGTH = 280;
function isCapError(payload: unknown): boolean {
return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED");
@@ -32,6 +35,7 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
const [editDescription, setEditDescription] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [expandedGoalDescriptions, setExpandedGoalDescriptions] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (initialGoals !== undefined) {
@@ -187,6 +191,22 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
}
}
function isDescriptionToggleVisible(description: string): boolean {
return description.length > GOAL_DESCRIPTION_TOGGLE_LENGTH || description.includes("\n");
}
function toggleGoalDescription(goalId: string) {
setExpandedGoalDescriptions((current) => {
const next = new Set(current);
if (next.has(goalId)) {
next.delete(goalId);
} else {
next.add(goalId);
}
return next;
});
}
async function updateGoalArchiveStatus(goal: Goal) {
const endpoint = goal.status === "active" ? `/api/goals/${goal.id}/archive` : `/api/goals/${goal.id}/unarchive`;
@@ -357,7 +377,31 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
<>
<div className="goals-card-main">
<h3 className="goals-card-title">{goal.title}</h3>
{goal.description ? <p className="goals-card-description">{goal.description}</p> : null}
{goal.description ? (
(() => {
const showToggle = isDescriptionToggleVisible(goal.description);
const isExpanded = expandedGoalDescriptions.has(goal.id);
return (
<>
<div className={`markdown-body goals-card-description ${showToggle && !isExpanded ? "goals-card-description-collapsed" : ""}`.trim()}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{goal.description}</ReactMarkdown>
</div>
{showToggle ? (
<button
type="button"
className="btn goals-card-description-toggle"
aria-expanded={isExpanded}
data-testid={`goal-description-toggle-${goal.id}`}
onClick={() => toggleGoalDescription(goal.id)}
>
{isExpanded ? "Show less" : "Show more"}
</button>
) : null}
</>
);
})()
) : null}
<p className="goals-card-status">Status: {goal.status}</p>
</div>
<div className="goals-card-actions">

View File

@@ -241,4 +241,46 @@ describe("GoalsView", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Unable to save goal right now. Please try again.");
});
it("renders markdown description as formatted HTML", () => {
render(
<GoalsView
initialGoals={[
makeGoal({
id: "g1",
title: "Markdown Goal",
description: "**bold**\n\n- first item\n- second item",
}),
]}
/>,
);
expect(screen.getByText("bold", { selector: "strong" })).toBeInTheDocument();
expect(screen.getByText("first item", { selector: "li" })).toBeInTheDocument();
expect(screen.queryByText("**bold**")).not.toBeInTheDocument();
});
it("collapses long descriptions by default and toggles expanded state", () => {
const longDescription = `${"Long description content ".repeat(20)}extra`;
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: longDescription })]} />);
const toggle = screen.getByTestId("goal-description-toggle-g1");
const description = screen.getByText(/Long description content/i).closest(".goals-card-description");
expect(toggle).toHaveTextContent("Show more");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(description).toHaveClass("goals-card-description-collapsed");
fireEvent.click(toggle);
expect(toggle).toHaveTextContent("Show less");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(description).not.toHaveClass("goals-card-description-collapsed");
});
it("does not render description toggle for short single-line text", () => {
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Short goal description" })]} />);
expect(screen.queryByTestId("goal-description-toggle-g1")).not.toBeInTheDocument();
});
});