mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
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:
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user