https://fve.meiermade.com/components/page-examples/scheduling?state=ready&item=team-debrief&view=day
open System
open FSharp.ViewEngine
open Acme.Components.Primitives
open Acme.Components.Application
open Acme.Components.Documentation
open type Html
open type Svg
open type Datastar
type ExamplePage =
| DependencyGraph
| ExecutionDetail
| FinancialReporting
| Messaging
| Operations
| Scheduling
| MediaManagement
type ReviewState =
| Setup
| Ready
| Loading
| Empty
| Failed
type ExampleQuery =
{ state: ReviewState
item: string
view: string
range: string }
let defaultQuery =
{ state = Ready
item = ""
view = ""
range = "" }
let slug =
function
| DependencyGraph -> "dependency-graph"
| ExecutionDetail -> "execution-detail"
| FinancialReporting -> "financial-reporting"
| Messaging -> "messaging"
| Operations -> "operations-dashboard"
| Scheduling -> "scheduling"
| MediaManagement -> "media-management"
let title =
function
| DependencyGraph -> "Dependency graph"
| ExecutionDetail -> "Execution detail"
| FinancialReporting -> "Financial reporting"
| Messaging -> "Messaging"
| Operations -> "Operations dashboard"
| Scheduling -> "Scheduling"
| MediaManagement -> "Media management"
let url page =
"/components/page-examples/" + slug page
let stateKey =
function
| Setup -> "setup"
| Ready -> "ready"
| Loading -> "loading"
| Empty -> "empty"
| Failed -> "error"
let queryUrl page query =
let pairs =
[ "state", stateKey query.state
"item", query.item
"view", query.view
"range", query.range ]
url page
+ "?"
+ (pairs
|> List.filter (snd >> String.IsNullOrEmpty >> not)
|> List.map (fun (key, value) -> key + "=" + Uri.EscapeDataString value)
|> String.concat "&")
let link (href: string) (label: string) : HtmlElement =
a {
_href href
_class
"font-medium text-[var(--fve-brand-text)] underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-[var(--fve-brand-ring)]"
label
}
// Ordinary consumer-authored anchors for compact page-header destinations.
let pageLink (href:string) (label:string) =
a {
_href href
_class "inline-flex min-h-8 items-center justify-center rounded-[var(--fve-radius-control)] bg-[var(--fve-surface)] px-3 py-1.5 text-sm font-medium leading-5 text-[var(--fve-text)] ring-1 ring-inset ring-[var(--fve-border)] hover:bg-[var(--fve-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fve-brand-ring)]"
label
}
let section (heading: string) (content: HtmlElement) =
Section.create (SectionHeader.create heading |> SectionHeader.withDivider) content
|> Section.render
let details (fields: (string * string) list) =
DescriptionList.create [ for label, value in fields -> DescriptionListItem.text label value ]
|> DescriptionList.withColumns DescriptionListColumns.Three
|> DescriptionList.render
let reviewStates page =
(if page = Operations then [ Setup, "Setup" ] else [])
@ [ Ready, "Populated"; Loading, "Loading"; Empty, "Empty"; Failed, "Error" ]
let stateContent page query content =
match query.state with
| Setup
| Ready -> content
| Loading ->
div {
_class "p-8"
LoadingIndicator.create ("Loading " + (title page).ToLowerInvariant())
|> LoadingIndicator.withVisibleLabel
|> LoadingIndicator.render
}
| Empty ->
let heading, description, action =
match page with
| DependencyGraph -> "No dependencies found", "There are no dependencies for this selection.", "View dependencies"
| ExecutionDetail -> "No execution selected", "Choose an execution to inspect its timing and logs.", "View latest execution"
| FinancialReporting -> "No activity for this period", "Choose a period with recorded balances and transactions.", "View recent activity"
| Messaging -> "No messages in this conversation", "Choose a conversation to read its messages.", "View conversations"
| Operations -> "No sessions scheduled", "Check the schedule for upcoming sessions.", "View schedule"
| Scheduling -> "No sessions in this period", "Return to the current period to see upcoming sessions.", "View current schedule"
| MediaManagement -> "No media in this selection", "Browse the library to choose an asset.", "Browse media"
EmptyState.create heading description
|> EmptyState.withActions (link (queryUrl (if page = Operations then Scheduling else page) { query with state = Ready }) action)
|> EmptyState.render
| Failed ->
Notice.create
"workspace-error"
"This view could not be loaded"
(p { "Your selection is preserved. Try loading it again." })
|> Notice.withColor NoticeColor.Error
|> Notice.withActions (link (queryUrl page { query with state = Ready }) "Try again")
|> Notice.render
/// Place durable workspace identity above the scrollable destination list.
let sideNavWorkspace (name: string) =
div {
p {
_class "text-xs font-semibold uppercase tracking-wide text-[var(--fve-muted-text)]"
"Workspace"
}
p {
_class "mt-1 truncate text-sm font-semibold"
name
}
}
let workspace page heading subtitle canvas (actions:HtmlElement list) content =
let product, workspaceName, destinations =
match page with
| DependencyGraph
| ExecutionDetail ->
"Relay", "Customer analytics", [ DependencyGraph, "Dependencies"; ExecutionDetail, "Executions" ]
| FinancialReporting -> "Ledger", "Meier Made", [ FinancialReporting, "Overview" ]
| Messaging -> "Gather", "Northwind Outdoor", [ Messaging, "Messages" ]
| _ ->
"Fieldwork", "Northwind Outdoor", [ Operations, "Overview"; Scheduling, "Schedule"; MediaManagement, "Media" ]
let items =
[ for destination, label in destinations -> SideNavItem.create destination label ]
let navigation =
SideNav.create
(slug page + "-nav")
(product + " navigation")
(SideNavHeader.create product)
[ SideNavSection.ungrouped items ]
|> SideNav.withCurrent page
|> SideNav.withWidth SideNavWidth.Narrow
|> SideNav.withContext (sideNavWorkspace workspaceName)
|> SideNav.withFooter (
div {
_class "flex min-w-0 items-center gap-3"
Avatar.create "Andy Meier" "AM" |> Avatar.render
p {
_class "truncate text-sm font-medium"
"Andy Meier"
}
}
)
let header =
PageHeader.create heading
|> PageHeader.withSubtitle subtitle
|> (if List.isEmpty actions then id else PageHeader.withActions (div { _id (slug page + "-actions"); _class "flex flex-wrap items-center gap-3"; for action in actions do action }))
let topBar =
PageTopBar.create ()
|> PageTopBar.withContent (
div {
_class "flex min-w-0 flex-1 items-center"
Breadcrumbs.create
(slug page + "-breadcrumbs")
(product + " breadcrumb")
[ BreadcrumbItem.create page product; BreadcrumbItem.create page heading ]
|> Breadcrumbs.render url
}
)
let body =
Page.create header content
|> Page.withWidth PageWidth.Full
|> Page.withBodyLayout (
if canvas then
PageBodyLayout.Canvas
else
PageBodyLayout.Padded
)
|> Page.render
let framedBody =
div {
_class "flex h-full min-h-0 flex-col"
div { _class "hidden shrink-0 md:block"; topBar |> PageTopBar.render }
div { _class "min-h-0 flex-1"; body }
}
AppShell.create (slug page + "-shell") navigation framedBody
|> AppShell.withTheme (
ComponentsTheme.sky
|> ComponentsTheme.withDensity Density.Compact
|> ComponentsTheme.withControlSize ControlSize.Small)
|> AppShell.withBoundary AppShellBoundary.Container
|> AppShell.asPreview (product + " workspace")
|> AppShell.render url
type ScheduledEvent =
{ id: string
name: string
date: DateOnly
time: string
start: TimeOnly
finish: TimeOnly
people: string
kind: string
photo: string }
let scheduledEvents =
[ { id = "lesson-201"
name = "Coastal trail lesson"
date = DateOnly(2026, 9, 17)
time = "10:00–11:30 AM"
start = TimeOnly(10, 0)
finish = TimeOnly(11, 30)
people = "Maya and Sam · Instructor: Andy Meier"
kind = "Lesson"
photo = "photo-303" }
{ id = "camp-017"
name = "Beginner riding camp"
date = DateOnly(2026, 9, 17)
time = "10:30 AM–12:00 PM"
start = TimeOnly(10, 30)
finish = TimeOnly(12, 0)
people = "3 riders · Instructor: Jordan"
kind = "Camp"
photo = "photo-301" }
{ id = "lesson-202"
name = "Cornering fundamentals"
date = DateOnly(2026, 9, 18)
time = "9:00–10:00 AM"
start = TimeOnly(9, 0)
finish = TimeOnly(10, 0)
people = "Riley · Instructor: Andy Meier"
kind = "Lesson"
photo = "photo-302" }
{ id = "ride-019"
name = "Open track practice"
date = DateOnly(2026, 9, 19)
time = "1:00–3:00 PM"
start = TimeOnly(13, 0)
finish = TimeOnly(15, 0)
people = "6 riders · Supervisor: Jordan"
kind = "Practice"
photo = "photo-304" }
{ id = "return-020"
name = "Equipment return"
date = DateOnly(2026, 9, 24)
time = "4:00–4:30 PM"
start = TimeOnly(16, 0)
finish = TimeOnly(16, 30)
people = "Equipment team · Andy Meier"
kind = "Equipment"
photo = "photo-304" } ]
let eventUrl event =
queryUrl Scheduling { defaultQuery with item = event.id }
let scheduling query =
let event = scheduledEvents |> List.tryFind (fun event -> event.id = query.item)
let content, heading =
match event with
| Some event ->
div {
_class "grid gap-8"
details
[ "Date", event.date.ToString("MMMM d, yyyy", Globalization.CultureInfo.InvariantCulture)
"Time", event.time
"Type", event.kind
"Participants", event.people
"Location", "Coastal training grounds"
"Status", "Confirmed" ]
section
"Session preparation"
(ul {
_class "list-disc space-y-2 pl-5 text-sm"
li { "Check helmets and protective equipment before riding." }
li { "Meet at the east entrance 15 minutes before the session." }
li { "Water and a shaded rest area are available beside the track." }
})
section
"Session assets"
(link
(queryUrl MediaManagement { defaultQuery with item = event.photo })
"View matching asset")
link (url Scheduling) "Back to schedule"
},
event.name
| None ->
let view =
match query.view with
| "day" | "week" | "year" -> query.view
| _ -> "month"
let today = DateOnly(2026, 9, 17)
let offset =
match Int32.TryParse query.range with
| true, value -> Math.Clamp(value, -366, 366)
| _ -> 0
let start = today.AddDays offset
let startValue = start.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
let step direction =
let target =
match view with
| "day" -> start.AddDays direction
| "week" -> start.AddDays(direction * 7)
| "year" -> start.AddYears direction
| _ -> start.AddMonths direction
target.DayNumber - today.DayNumber
let events =
[ for event in scheduledEvents ->
CalendarEvent.create event.id event.name event.date (eventUrl event)
|> CalendarEvent.withTime event.start event.finish
|> CalendarEvent.withDetail event.people ]
let dateDestination (date:DateOnly) = queryUrl Scheduling { query with view = "day"; range = string (date.DayNumber - today.DayNumber) }
let todayDestination = queryUrl Scheduling { query with range = "0" }
let previous = queryUrl Scheduling { query with range = string (step -1) }
let next = queryUrl Scheduling { query with range = string (step 1) }
let calendar =
match view with
| "day" ->
DayCalendar.create "Daily sessions" start events
|> DayCalendar.withToday today todayDestination
|> DayCalendar.withSelectedDate start
|> DayCalendar.withDateDestination dateDestination
|> (if step -1 >= -366 then DayCalendar.withPrevious previous else id)
|> (if step 1 <= 366 then DayCalendar.withNext next else id)
|> DayCalendar.render id
| "week" ->
WeekCalendar.create "Weekly sessions" start events
|> WeekCalendar.withToday today todayDestination
|> WeekCalendar.withSelectedDate start
|> WeekCalendar.withDateDestination dateDestination
|> (if step -1 >= -366 then WeekCalendar.withPrevious previous else id)
|> (if step 1 <= 366 then WeekCalendar.withNext next else id)
|> WeekCalendar.render id
| "year" ->
YearCalendar.create "Annual sessions" start events
|> YearCalendar.withToday today todayDestination
|> YearCalendar.withSelectedDate start
|> YearCalendar.withDateDestination dateDestination
|> (if step -1 >= -366 then YearCalendar.withPrevious previous else id)
|> (if step 1 <= 366 then YearCalendar.withNext next else id)
|> YearCalendar.render id
| _ ->
MonthCalendar.create "Monthly sessions" start events
|> MonthCalendar.withToday today todayDestination
|> MonthCalendar.withSelectedDate start
|> MonthCalendar.withDateDestination dateDestination
|> (if step -1 >= -366 then MonthCalendar.withPrevious previous else id)
|> (if step 1 <= 366 then MonthCalendar.withNext next else id)
|> MonthCalendar.render id
Html.section {
_ariaLabel "Schedule"
_attr ("data-view", view)
_class "grid min-w-0 gap-4"
nav {
_ariaLabel "Schedule calendar view"
_class "flex flex-wrap gap-1"
for value, label in [ "day", "Day"; "week", "Week"; "month", "Month"; "year", "Year" ] do
a {
_href (queryUrl Scheduling { query with view = value })
if view = value then _ariaCurrent "page"
_class "inline-flex min-h-8 items-center rounded-[var(--fve-radius-control)] px-3 py-1 text-sm font-medium no-underline hover:bg-[var(--fve-surface-hover)] focus-visible:outline-2 focus-visible:outline-[var(--fve-brand-ring)] aria-[current=page]:bg-[var(--fve-brand-subtle)] aria-[current=page]:text-[var(--fve-brand-text)]"
label
}
}
div {
_id ("scheduling-calendar-" + view + "-" + startValue)
_dataInit $"el.fveCalendarObserver?.disconnect(); const cell = el.querySelector('[data-date=\"{startValue}\"]'); const region = cell?.closest('[role=region]'); if (cell && region) {{ const observer = new ResizeObserver(() => {{ if (!el.isConnected) {{ observer.disconnect(); return }}; if (!region.clientWidth) return; const c = cell.getBoundingClientRect(), r = region.getBoundingClientRect(); const hours = region.querySelector('.fve-calendar-hours')?.clientWidth || 0; region.scrollLeft += c.left - r.left - Math.max(hours, (region.clientWidth - c.width) / 2) }}); el.fveCalendarObserver = observer; observer.observe(region) }}"
calendar
}
},
"Schedule"
workspace
Scheduling
heading
"Eastern time"
false
[]
(stateContent Scheduling query content)
div { _class "h-[44rem]"; scheduling defaultQuery }
|> Browser.create
|> Browser.withAddress "https://fve.meiermade.com/components/page-examples/scheduling?state=ready"
|> Browser.render
|> Fixture.create "page-workspace" "Workspace" "/components/page-examples/scheduling?state=ready"
|> Fixture.withFullscreenContent (scheduling defaultQuery)
|> Fixture.render