feat(experience): add role progression to show career advancement within a company (#2761)

* feat(experience): add role progression support for career advancement

* fix(experience): remove indent and border from role progression items

* refactor(experience): enhance role progression UI and functionality

- Updated the ExperienceItem component to improve role display and styling.
- Refactored the CreateExperienceDialog and UpdateExperienceDialog to support role reordering using drag-and-drop.
- Added role handling in JSON and ReactiveResume importers.
- Enhanced experience schema to include roles, ensuring better data structure for career progression.
- Improved overall user experience with clearer role management in the experience section.

---------

Co-authored-by: root <root@reactive-resume-dev.one.one.one.one>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
TheDuke427
2026-03-04 12:34:37 -08:00
committed by GitHub
parent df81d0342f
commit 4b46bda4e6
8 changed files with 271 additions and 49 deletions
@@ -10,11 +10,13 @@ type ExperienceItemProps = SectionItem<"experience"> & {
};
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
return (
<div className={cn("experience-item", className)}>
{/* Header */}
<div className="section-item-header experience-item-header">
{/* Row 1 */}
{/* Row 1: Company + Location */}
<div className="flex items-start justify-between gap-x-2">
<LinkedTitle
title={item.company}
@@ -25,19 +27,55 @@ export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
<span className="section-item-metadata experience-item-location shrink-0 text-end">{item.location}</span>
</div>
{/* Row 2 */}
<div className="flex items-start justify-between gap-x-2">
<span className="section-item-metadata experience-item-position">{item.position}</span>
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
</div>
{/* Row 2: Position + Period */}
{(!hasRoles || item.position) && (
<div className="flex items-start justify-between gap-x-2">
<span className="section-item-metadata experience-item-position">{item.position}</span>
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
</div>
)}
{/* Overall period when hasRoles and no summary position */}
{hasRoles && !item.position && item.period && (
<div className="flex items-start justify-end gap-x-2">
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
</div>
)}
</div>
{/* Description */}
<div
className={cn("section-item-description experience-item-description", !stripHtml(item.description) && "hidden")}
>
<TiptapContent content={item.description} />
</div>
{/* Role Progression */}
{hasRoles && (
<div className="experience-item-roles mt-(--page-gap-y) flex flex-col gap-y-(--page-gap-y)">
{item.roles.map((role) => (
<div key={role.id} className="experience-item-role">
<div className="flex items-start justify-between gap-x-2">
<strong className="section-item-metadata experience-item-role-position">{role.position}</strong>
<span className="section-item-metadata experience-item-role-period shrink-0 text-end">
{role.period}
</span>
</div>
{stripHtml(role.description) && (
<div className="section-item-description experience-item-role-description mt-0.5">
<TiptapContent content={role.description} />
</div>
)}
</div>
))}
</div>
)}
{/* Single-role description */}
{!hasRoles && (
<div
className={cn(
"section-item-description experience-item-description",
!stripHtml(item.description) && "hidden",
)}
>
<TiptapContent content={item.description} />
</div>
)}
{/* Website */}
{!item.options?.showLinkInTitle && (
+6 -6
View File
@@ -8,18 +8,18 @@ function Tabs({ className, orientation = "horizontal", ...props }: React.Compone
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full p-1 text-muted-foreground data-[variant=line]:rounded-none group-data-[orientation=vertical]/tabs:h-fit group-data-horizontal/tabs:h-9 group-data-[orientation=vertical]/tabs:flex-col",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full px-1.5 py-0.25 text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
{
variants: {
variant: {
default: "bg-card",
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
@@ -49,10 +49,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-transparent px-3 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=vertical]/tabs:after:-inset-e-1 group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"data-active:bg-background data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
+174 -26
View File
@@ -1,7 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
import { useMemo } from "react";
import { useFieldArray, useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
@@ -14,6 +16,7 @@ import { Switch } from "@/components/ui/switch";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import type { RoleItem } from "@/schema/resume/data";
import { experienceItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
@@ -37,6 +40,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
roles: data?.item?.roles ?? [],
},
});
@@ -49,6 +53,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
draft.sections.experience.items.push(formData);
}
});
closeDialog();
};
@@ -99,6 +104,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
period: data.item.period,
website: data.item.website,
description: data.item.description,
roles: data.item.roles ?? [],
},
});
@@ -114,6 +120,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
if (index !== -1) draft.sections.experience.items[index] = formData;
}
});
closeDialog();
};
@@ -148,9 +155,114 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
);
}
type RoleFieldsProps = {
role: RoleItem;
index: number;
onRemove: () => void;
};
function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
const form = useFormContext<FormValues>();
const controls = useDragControls();
return (
<Reorder.Item
value={role}
dragListener={false}
dragControls={controls}
initial={{ opacity: 1, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="relative grid rounded border sm:col-span-full sm:grid-cols-2"
>
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
<Button
size="sm"
variant="ghost"
className="cursor-grab touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<RowsIcon />
<Trans>Reorder</Trans>
</Button>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={onRemove}>
<TrashSimpleIcon />
<Trans>Remove</Trans>
</Button>
</div>
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
<FormField
control={form.control}
name={`roles.${index}.position`}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`roles.${index}.period`}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`roles.${index}.description`}
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</Reorder.Item>
);
}
function ExperienceForm() {
const form = useFormContext<FormValues>();
const { fields, append, remove } = useFieldArray({
name: "roles",
keyName: "fieldId",
control: form.control,
});
const hasRoles = useMemo(() => fields.length > 0, [fields]);
const handleReorderRoles = (newOrder: RoleItem[]) => {
form.setValue("roles", newOrder);
};
return (
<>
<FormField
@@ -174,11 +286,9 @@ function ExperienceForm() {
name="position"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />
</FormControl>
<FormMessage />
</FormItem>
@@ -206,11 +316,9 @@ function ExperienceForm() {
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} placeholder={hasRoles ? "e.g. 2018 Present" : ""} />
</FormControl>
<FormMessage />
</FormItem>
@@ -246,28 +354,68 @@ function ExperienceForm() {
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormLabel>
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Role Progression */}
<div className="flex items-center justify-between sm:col-span-full">
<div className="space-y-1">
<p className="font-medium text-foreground">
<Trans>Role Progression</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Add multiple roles to show career progression at the same company.</Trans>
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => append({ id: generateId(), position: "", period: "", description: "" })}
>
<PlusIcon />
<Trans>Add Role</Trans>
</Button>
</div>
{hasRoles && (
<Reorder.Group
axis="y"
values={fields}
onReorder={handleReorderRoles}
className="flex flex-col gap-4 sm:col-span-full"
>
<AnimatePresence>
{fields.map((field, index) => (
<RoleFields key={field.id} role={fields[index]} index={index} onRemove={() => remove(index)} />
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Single Role Description — only show when no roles are defined */}
{!hasRoles && (
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
}
+1
View File
@@ -409,6 +409,7 @@ export class JSONResumeImporter {
location: work.location || "",
period: formatPeriod(work.startDate, work.endDate),
website: createUrl(work.url),
roles: [],
description: toHtmlDescription(work.summary, work.highlights),
})),
};
@@ -482,6 +482,7 @@ export class ReactiveResumeV4JSONImporter {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
},
roles: [],
description: item.summary ?? "",
})),
},
@@ -684,6 +685,7 @@ export class ReactiveResumeV4JSONImporter {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
},
roles: [],
description: item.summary ?? "",
})),
})),
@@ -1,3 +1,4 @@
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import type z from "zod";
@@ -21,9 +22,17 @@ export function ExperienceSectionBuilder() {
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="experience" item={item} title={item.company} subtitle={item.position} />
))}
{section.items.map((item) => {
return (
<SectionItem
key={item.id}
type="experience"
item={item}
title={item.company}
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
/>
);
})}
</AnimatePresence>
</Reorder.Group>
+23 -2
View File
@@ -131,13 +131,34 @@ export const educationItemSchema = baseItemSchema.extend({
description: z.string().describe("The description of the education. This should be a HTML-formatted string."),
});
export const roleItemSchema = z.object({
id: z.string().describe("The unique identifier for the role. Usually generated as a UUID."),
position: z.string().describe("The position or job title for this role."),
period: z.string().describe("The period of time this role was held."),
description: z.string().describe("The description of this specific role. This should be a HTML-formatted string."),
});
export type RoleItem = z.infer<typeof roleItemSchema>;
export const experienceItemSchema = baseItemSchema.extend({
company: z.string().min(1).describe("The name of the company or organization."),
position: z.string().describe("The position held at the company or organization."),
position: z
.string()
.describe(
"The position held at the company or organization. Used when there is only a single role. If multiple roles are provided in the 'roles' field, this serves as a summary title or can be left blank.",
),
location: z.string().describe("The location of the company or organization."),
period: z.string().describe("The period of time the author was employed at the company or organization."),
period: z
.string()
.describe(
"The overall period of time at the company. When multiple roles are used, this should reflect the total tenure.",
),
website: urlSchema.describe("The website of the company or organization, if any."),
description: z.string().describe("The description of the experience. This should be a HTML-formatted string."),
roles: z
.array(roleItemSchema)
.catch([])
.describe("Optional list of individual roles held at this company to show career progression."),
});
export const interestItemSchema = baseItemSchema.extend({
+3
View File
@@ -91,6 +91,7 @@ export const sampleResumeData: ResumeData = {
url: "",
label: "",
},
roles: [],
description:
"<ul><li><p>Lead gameplay programmer on an unannounced AAA action-adventure title built in Unreal Engine 5 for PC and next-gen consoles</p></li><li><p>Architected and implemented core combat system including hit detection, combo mechanics, and enemy AI behavior trees serving 15+ enemy types</p></li><li><p>Developed custom editor tools in C++ that reduced level designer iteration time by 40% and improved workflow efficiency across the team</p></li><li><p>Optimized rendering pipeline and gameplay systems to maintain 60 FPS performance target on all supported platforms, achieving 95% frame rate stability</p></li><li><p>Ad nostrud enim adipisicing ea proident aliqua veniam nisi amet ea irure et mollit.</p></li></ul><p></p>",
},
@@ -449,6 +450,7 @@ export const sampleResumeData: ResumeData = {
url: "",
label: "",
},
roles: [],
description:
"<ul><li>Core developer on 'Starbound Odyssey,' a sci-fi roguelike that achieved 500K+ sales on Steam with 'Very Positive' user reviews</li><li>Implemented procedural generation systems for level layouts, enemy encounters, and loot drops using Unity and C#</li><li>Designed and programmed player progression systems including skill trees, equipment upgrades, and meta-progression mechanics</li><li>Created robust save/load system supporting cloud saves and cross-platform play between PC and Nintendo Switch</li><li>Integrated third-party SDKs for analytics (GameAnalytics), achievements (Steamworks), and multiplayer networking (Photon)</li><li>Fixed critical bugs and balanced gameplay based on community feedback and telemetry data, releasing 12 post-launch content updates</li><li>Worked closely with artists to implement VFX, animations, and shaders that enhanced visual polish while maintaining performance targets</li></ul>",
},
@@ -463,6 +465,7 @@ export const sampleResumeData: ResumeData = {
url: "",
label: "",
},
roles: [],
description:
"<ul><li><p>Contributed to development of three mobile puzzle games built in Unity, collectively downloaded 2M+ times on iOS and Android</p></li><li><p>Implemented UI systems, touch controls, and gesture recognition optimized for mobile devices and various screen sizes</p></li><li><p>Developed monetization features including rewarded video ads, in-app purchases, and daily reward systems that increased retention by 25%</p></li><li><p>Optimized memory usage and load times for mobile platforms, reducing app size by 35% through asset compression and code optimization</p></li><li><p>Collaborated with game designers to balance puzzle difficulty curves and progression pacing using A/B testing data</p></li></ul><p></p>",
},