Skip to content
FC
← All Posts
reacttypescriptdata-visualizationcomponent-design

Visualizing Work History: Building a Career Graph in React

6 min read

The week was dense. Forty-one images reorganized, twelve component files touched, a full section replacement. But the change I keep thinking about is the one with the smallest line count: swapping out ExperienceTeaser for ExperienceGraph.

On the surface it looks like a rename with new visuals. Under the hood it required rethinking how I model career history data — and that decision cascades into everything: layout, animation, accessibility, responsiveness. Here's what actually happened.

Why the Teaser Pattern Breaks Down

ExperienceTeaser was doing what teasers do: pick a few items from a list, render them, add a "see more" link. Simple. Fine for a first pass. But when an experience section is supposed to tell a story rather than just list employers, the teaser pattern actively works against you.

The problems stack up fast:

  • It's chronologically flat. Three cards in a grid don't communicate progression or duration.
  • It forces the user to navigate away before they can understand scope.
  • It can't answer "how long?" at a glance.

A timeline graph solves all three. Duration becomes width. Career shape becomes readable in two seconds. No extra page needed.

Designing the Data Model First

Before writing a single component, I needed to decide what the data looks like. The original experience.ts was shaped for rendering, not computing:

// Before: period as a display string
export interface ExperienceItem {
  company: string;
  role: string;
  period: string; // "2023 – Present"
  description: string[];
  technologies: string[];
}

The string-only period field is the killer. You can display it, but you can't compute with it. Drawing bars proportional to duration requires real dates.

The updated shape:

export interface ExperienceEntry {
  company: string;
  role: string;
  startDate: string;    // "2022-03" — ISO partial date
  endDate: string | null; // null = current role
  location: string;
  description: string[];
  technologies: string[];
  type: "full-time" | "contract" | "freelance";
}

export type ExperienceData = ExperienceEntry[];

Two changes matter most. First, startDate and endDate as ISO partial strings instead of display strings — sortable, computable, unambiguous. Second, endDate: string | null — an explicit null for current roles avoids the "Present" string hack and lets the component substitute today's date when calculating width.

Building the Graph Component

With computable dates in the data layer, the component logic becomes straightforward. The core idea: convert every entry into a percentage width relative to the total career span, then position it as an absolutely-placed bar.

// components/sections/ExperienceGraph.tsx
function toMonths(dateStr: string): number {
  const [year, month] = dateStr.split("-").map(Number);
  return year * 12 + (month ?? 1);
}

function getSpan(
  entry: ExperienceEntry,
  careerStartMonths: number,
  totalMonths: number
) {
  const start = toMonths(entry.startDate);
  const end = entry.endDate
    ? toMonths(entry.endDate)
    : toMonths(new Date().toISOString().slice(0, 7));

  const left = ((start - careerStartMonths) / totalMonths) * 100;
  const width = ((end - start) / totalMonths) * 100;
  return { left, width };
}

export function ExperienceGraph({ data }: { data: ExperienceData }) {
  const allStarts = data.map((e) => toMonths(e.startDate));
  const careerStart = Math.min(...allStarts);
  const now = toMonths(new Date().toISOString().slice(0, 7));
  const totalMonths = now - careerStart;

  return (
    <div className="relative w-full space-y-3">
      {data.map((entry) => {
        const { left, width } = getSpan(entry, careerStart, totalMonths);
        return (
          <div key={`${entry.company}-${entry.startDate}`} className="relative h-10">
            <div
              className="absolute h-full rounded-md bg-primary/20 border border-primary/40
                         flex items-center px-3 text-sm truncate"
              style={{ left: `${left}%`, width: `${Math.max(width, 2)}%` }}
            >
              <span className="font-medium">{entry.company}</span>
            </div>
          </div>
        );
      })}
    </div>
  );
}

The Math.max(width, 2) is defensive: a very short contract shouldn't render as an invisible sliver. Two percent minimum keeps every bar tappable and legible.

Responsiveness Is the Hard Part

Percentage-width bars work well on desktop. On mobile, a 400-month career span compressed into 360px means shorter roles become unreadable smears. Two approaches I seriously considered:

Option A: Horizontal scroll container. Wrap the graph in an overflow container with a fixed minimum width. The user swipes to explore. It works, but feels awkward on a portfolio where everything else scrolls vertically.

Option B: Responsive fallback to a list. Below a breakpoint, render a vertical list with duration badges instead of the graph. Same data, different presentation.

I went with Option B. The graph earns its place on desktop, where the full timeline fits in one view. On mobile, a clean stacked list with formatted duration labels communicates the same information more reliably.

<div className="hidden md:block">
  <ExperienceGraph data={experience} />
</div>
<div className="block md:hidden">
  <ExperienceList data={experience} />
</div>

No JavaScript, no resize listeners — just Tailwind breakpoints. Both components share the same ExperienceData type; only the visual treatment differs. The data layer doesn't care which one renders.

TypeScript Catches What You'd Miss

Tightening the data model paid off in an unexpected way: TypeScript immediately flagged two entries where I'd left endDate as an empty string "" instead of null. The old string-based shape silently accepted those. The new shape surfaces them as type errors before anything renders.

That's the real value of typing your data layer — not the IDE autocomplete (useful, but secondary), but the fact that malformed data becomes a compile-time error instead of a layout bug you only notice in production. It also kept the component signature minimal: ExperienceGraph takes data: ExperienceData and nothing else. No optional display flags, no prop drilling. The data shape itself carries enough information for the component to make its own decisions.

"Coming Soon" Follows the Same Pattern

While restructuring the experience section, I added explicit "Coming Soon" support to the projects grid using the same philosophy: represent state in the data, not scattered across template conditionals.

export interface Project {
  slug: string;
  title: string;
  status: "live" | "coming-soon" | "archived";
  // ...
}

The project card reads status and renders accordingly. No if (!project.isDeployed) checks. No boolean flags. Just a typed union that the component switches on. You can see the live version of this approach across projects like OrderX and Sparta Store, where the status field controls what the card actually surfaces.

Takeaway

Replacing ExperienceTeaser with ExperienceGraph wasn't a cosmetic change — it was a data modeling decision that required upgrading the underlying data shape before the component could be written correctly. The visual output is almost a byproduct.

The broader pattern holds: when a component feels hard to build, the data it's consuming is usually the wrong shape. Fix the model first. The component nearly writes itself after that.

TypeScript helps here not as a type-checker but as a data-modeling tool. Explicit null, proper union types, and structured date strings are constraints that eliminate entire classes of runtime bugs before you ever open a browser.

← Back to Blog