diff --git a/src/components/resume/shared/items/experience-item.tsx b/src/components/resume/shared/items/experience-item.tsx index f748fc418..d77edcfdf 100644 --- a/src/components/resume/shared/items/experience-item.tsx +++ b/src/components/resume/shared/items/experience-item.tsx @@ -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 (
{/* Header */}
- {/* Row 1 */} + {/* Row 1: Company + Location */}
{item.location}
- {/* Row 2 */} -
- {item.position} - {item.period} -
+ {/* Row 2: Position + Period */} + {(!hasRoles || item.position) && ( +
+ {item.position} + {item.period} +
+ )} + + {/* Overall period when hasRoles and no summary position */} + {hasRoles && !item.position && item.period && ( +
+ {item.period} +
+ )}
- {/* Description */} -
- -
+ {/* Role Progression */} + {hasRoles && ( +
+ {item.roles.map((role) => ( +
+
+ {role.position} + + {role.period} + +
+ + {stripHtml(role.description) && ( +
+ +
+ )} +
+ ))} +
+ )} + + {/* Single-role description */} + {!hasRoles && ( +
+ +
+ )} {/* Website */} {!item.options?.showLinkInTitle && ( diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index b5de2d4de..e47e803ca 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -8,18 +8,18 @@ function Tabs({ className, orientation = "horizontal", ...props }: React.Compone ); } 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 void; +}; + +function RoleFields({ role, index, onRemove }: RoleFieldsProps) { + const form = useFormContext(); + const controls = useDragControls(); + + return ( + +
+ + + +
+ +
+ ( + + + Position + + + + + + + )} + /> + + ( + + + Period + + + + + + + )} + /> + + ( + + + Description + + + + + + + )} + /> +
+
+ ); +} + function ExperienceForm() { const form = useFormContext(); + 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 ( <> ( - - Position - + {hasRoles ? Overall Title (optional) : Position} - + @@ -206,11 +316,9 @@ function ExperienceForm() { name="period" render={({ field }) => ( - - Period - + {hasRoles ? Overall Period : Period} - + @@ -246,28 +354,68 @@ function ExperienceForm() { - + Show link in title )} /> - ( - - - Description - - - - - - - )} - /> + {/* Role Progression */} +
+
+

+ Role Progression +

+

+ Add multiple roles to show career progression at the same company. +

+
+ + +
+ + {hasRoles && ( + + + {fields.map((field, index) => ( + remove(index)} /> + ))} + + + )} + + {/* Single Role Description — only show when no roles are defined */} + {!hasRoles && ( + ( + + + Description + + + + + + + )} + /> + )} ); } diff --git a/src/integrations/import/json-resume.tsx b/src/integrations/import/json-resume.tsx index 478899d14..165fa34bd 100644 --- a/src/integrations/import/json-resume.tsx +++ b/src/integrations/import/json-resume.tsx @@ -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), })), }; diff --git a/src/integrations/import/reactive-resume-v4-json.tsx b/src/integrations/import/reactive-resume-v4-json.tsx index 9a278d186..3282a0d8d 100644 --- a/src/integrations/import/reactive-resume-v4-json.tsx +++ b/src/integrations/import/reactive-resume-v4-json.tsx @@ -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 ?? "", })), })), diff --git a/src/routes/builder/$resumeId/-sidebar/left/sections/experience.tsx b/src/routes/builder/$resumeId/-sidebar/left/sections/experience.tsx index 1e1934361..f6a68d9cf 100644 --- a/src/routes/builder/$resumeId/-sidebar/left/sections/experience.tsx +++ b/src/routes/builder/$resumeId/-sidebar/left/sections/experience.tsx @@ -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() { - {section.items.map((item) => ( - - ))} + {section.items.map((item) => { + return ( + + ); + })} diff --git a/src/schema/resume/data.ts b/src/schema/resume/data.ts index c1172323b..a99f87aad 100644 --- a/src/schema/resume/data.ts +++ b/src/schema/resume/data.ts @@ -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; + 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({ diff --git a/src/schema/resume/sample.ts b/src/schema/resume/sample.ts index b4fcfa283..3c22abe87 100644 --- a/src/schema/resume/sample.ts +++ b/src/schema/resume/sample.ts @@ -91,6 +91,7 @@ export const sampleResumeData: ResumeData = { url: "", label: "", }, + roles: [], description: "
  • Lead gameplay programmer on an unannounced AAA action-adventure title built in Unreal Engine 5 for PC and next-gen consoles

  • Architected and implemented core combat system including hit detection, combo mechanics, and enemy AI behavior trees serving 15+ enemy types

  • Developed custom editor tools in C++ that reduced level designer iteration time by 40% and improved workflow efficiency across the team

  • Optimized rendering pipeline and gameplay systems to maintain 60 FPS performance target on all supported platforms, achieving 95% frame rate stability

  • Ad nostrud enim adipisicing ea proident aliqua veniam nisi amet ea irure et mollit.

", }, @@ -449,6 +450,7 @@ export const sampleResumeData: ResumeData = { url: "", label: "", }, + roles: [], description: "
  • Core developer on 'Starbound Odyssey,' a sci-fi roguelike that achieved 500K+ sales on Steam with 'Very Positive' user reviews
  • Implemented procedural generation systems for level layouts, enemy encounters, and loot drops using Unity and C#
  • Designed and programmed player progression systems including skill trees, equipment upgrades, and meta-progression mechanics
  • Created robust save/load system supporting cloud saves and cross-platform play between PC and Nintendo Switch
  • Integrated third-party SDKs for analytics (GameAnalytics), achievements (Steamworks), and multiplayer networking (Photon)
  • Fixed critical bugs and balanced gameplay based on community feedback and telemetry data, releasing 12 post-launch content updates
  • Worked closely with artists to implement VFX, animations, and shaders that enhanced visual polish while maintaining performance targets
", }, @@ -463,6 +465,7 @@ export const sampleResumeData: ResumeData = { url: "", label: "", }, + roles: [], description: "
  • Contributed to development of three mobile puzzle games built in Unity, collectively downloaded 2M+ times on iOS and Android

  • Implemented UI systems, touch controls, and gesture recognition optimized for mobile devices and various screen sizes

  • Developed monetization features including rewarded video ads, in-app purchases, and daily reward systems that increased retention by 25%

  • Optimized memory usage and load times for mobile platforms, reducing app size by 35% through asset compression and code optimization

  • Collaborated with game designers to balance puzzle difficulty curves and progression pacing using A/B testing data

", },