Skip to main content
All work

PRODUCT BUILD

Orvia

A week of retail shifts is a constraint-satisfaction problem. Most scheduling software gives you a better spreadsheet, Orvia generates the answer.

Next.js TypeScript Supabase
Role
Design + Engineering
Type
Solo build
Year
2025
Links
demo ↗ github ↗

Problem

A friend spent three hours a week rebuilding the same schedule by hand

She managed three retail stores and built every week’s shifts in Google Sheets, juggling availability, roles, hour caps, and fairness. Her options were a spreadsheet or enterprise software with a five-figure contract.

Challenge

Schedules run on rules, but people run on exceptions

Hour caps and role coverage can be coded. “She can’t open Tuesdays because of school pickup” is harder, and every manager has a list of them.

Solution

One click drafts the week; the manager fixes what’s off

I started with the data. A new store, role, or hire is just more data in Supabase, so the schedule can grow without a rebuild.

Then the rules. I wrote each one in plain text and had AI turn it into code. Every new corner case broke something else, so I left the last mile to the manager. Better obviously partial than confidently wrong.

 1–3  setup + state init
      workers, templates, recurring assignments, hours
      → exclude manager + inactive
      → keep cross-location shifts  (conflict guard)
      → new ScheduleGenerationState

  4   processRecurringAssignments()   ← highest priority
      stable weekly patterns placed first, slots locked

  5   processPairedPrepBaristaShifts()   ← specific location only
      prep + barista paired together

  6   assignLeads()
      open: start < 12:00  ·  close: end ≥ 21:00
      tie-break: prior week → fewest hours → highest job_level

  7   assignDynamicShifts()
      remaining unfilled slots
      tie-break: fewest hours → highest job_level → unassigned ⚠

  8   saveSchedule()
      → scheduled_shifts (write to Supabase)
      → shift_assignments (write to Supabase)

Generation pipeline. Setup in 1–3, assignment phases in 4–8.

The design system grew out of the screens. As patterns repeated, I pulled each color, type role, spacing value, and corner radius into a single :root block and documented it alongside the product, so designing in code wouldn’t drift into AI defaults.

Color tokens

Primary

color-deep-rose #B48883 Primary interactive elements that initiate an action — the single action color.
color-deep-rose-active #9E736E Pressed/hover state of primary interactive elements only.
color-rose-blush #CBA6A2 Selected/active state fill and outline — softer presence than deep-rose for persistent state.

Categorical

color-lavender-cream #D7CDEA Hover and focus fill on interactive surfaces — low-contrast, non-committal highlight.
color-lavender-mid #A597CE Active/selected text on secondary navigation elements.
color-pistachio-mist #D3E3D8 Positive status indicators — surfaces and text that communicate success or completion.

Neutral

color-charcoal-cocoa #2E2B29 Primary text and icon strokes — maximum readable contrast.
color-ash-mocha #847A75 Secondary and supporting text — reduced emphasis, never used for primary labels.
color-blush-cream #FEFCFB Full-page background — the base layer everything sits on.
color-card-ivory #FAF7F5 Raised surfaces: cards, table rows, modals — one step above the page.
color-oat-beige #F2EBE8 Column headers, hover targets, default badge fill — visually groups or highlights within a surface.
color-very-light-beige #DCD4CD Structural separators between surfaces — panels, grids, modals.
color-input-border #DFD2C4 Form field borders — lighter than very-light-beige to reduce visual weight inside dense form layouts.

Semantic

color-error-red #C34E46 Destructive states and validation failures — error text, conflict indicators, destructive confirmations.

Typography tokens

Manrope. A geometric sans-serif with slightly rounded strokes and open apertures. Designed to stay readable at small sizes without feeling cold.

Title
type-title 18px / 600 / 1.4 Page headers, modal titles, section names.
Body
type-body 14px / 400 / 1.5 Reading text, table cells, form descriptions.
Label
type-label 12px / 600 / 1.4 / 0.01em Nav items, button text, form labels, column headers.
Caption
type-caption 11px / 500 / 1.4 Sidebar footer, timestamps, secondary metadata.

Spacing tokens

spacing-xs 8px Tight internal padding within a single element.
spacing-sm 12px Compact element padding.
spacing-md 16px Standard element padding.
spacing-lg 24px Gaps between related groups or sections.
spacing-xl 32px Separation between major layout regions.

Layout, used directly

48px Generous section separation.
64px Major layout separations.
96px Sidebar logo top offset, full-page top margin.

Border radius tokens

radius-sm 8px Inline elements: chips, tags, small indicators.
radius-md 10px Interactive controls: buttons, inputs.
radius-lg 12px Container surfaces: cards, panels, modals.

The obvious product is a grid you fill in cell by cell. Orvia fills the week in one click, and the manager edits from a sorted list rather than dragging name pills, which turn into a wall to scan at fifty people.

Editing a shift in Orvia — clicking a schedule cell opens a modal with an alphabetically sorted employee list to reassign the slot

Clicking any cell opens the edit modal — an alphabetically sorted list to reassign the shift

Impact

Three hours a week, down to fifteen minutes

Orvia drafts a full week across her three stores in a couple of seconds. The rest of her time goes to the human exceptions no rule captures.

Orvia dashboard showing three retail locations — Avenlo, Cedar Loop, and Solmere — with staffing coverage and a weekly glance panel

Regional manager dashboard — three locations, current-week staffing coverage at a glance

Where it could go

Managers could type the exceptions

Generation would stay rule-based. But “Maria can’t open Tuesdays anymore” could update her availability on its own, since turning plain language into structured fields is a good fit for an LLM.