Files
sis/docs/plans/2026-07-15-hyperbole-port.md

21 KiB

Hyperbole Port Implementation Plan

Goal: Port Sis from Orb+WAI+TypeScript+Mithril to Hyperbole (pure Haskell serverside web framework), eliminating all JavaScript application code.

Architecture: Single Haskell Hyperbole application. Pages rendered serverside. Interactivity via HyperViews with WebSocket-based VirtualDOM. SQLite via custom effectful DB effect. Neo Brutalism CSS via CDN.

Tech Stack: Hyperbole, effectful, atomic-css, Warp, SQLite (sqlite-simple), crypton.


File Structure Map

File Action Responsibility
package.yaml Modify Replace Orb/Mithril deps with Hyperbole
stack.yaml Modify Add Hyperbole extra-deps
app/Main.hs Rewrite Hyperbole app entry, Warp, route dispatch
src/Sis.hs Modify Re-exports (remove Server, add Page modules)
src/Sis/Route.hs Create Route ADT with Route instance
src/Sis/Types.hs Modify Remove Aeson instances, add form types
src/Sis/Database.hs Rewrite effectful DB effect + SQLite handler
src/Sis/Auth.hs Modify Keep password hashing, drop cookie/session helpers
src/Sis/Server.hs Delete Replaced by Hyperbole pages
src/Sis/Page/Login.hs Create Login page
src/Sis/Page/Signup.hs Create Signup page
src/Sis/Page/Dashboard.hs Create Dashboard page
src/Sis/Page/Chores.hs Create Chores page
src/Sis/Page/Household.hs Create Household page
src/Sis/Page/Activity.hs Create Activity log page
src/Sis/View/Layout.hs Create Shell: document head, navbar, page wrapper
src/Sis/View/ChoreForm.hs Create Create/edit chore HyperView
src/Sis/View/ActivityForm.hs Create Record activity HyperView
src/Sis/View/Field.hs Create Reusable form field helpers
src/Sis/Style.hs Create Neo Brutalism class helpers
test/Spec.hs Modify Delete placeholder
frontend/src/ Delete All TypeScript code
frontend/index.html Delete Replaced by Hyperbole document function
frontend/package.json Delete No npm needed
frontend/tsconfig.json Delete No TypeScript needed
frontend/public/ Delete Merge into static/
frontend/static/style.css Create/Keep Move from public/style.css
frontend/static/manifest.json Create/Keep Move from public/manifest.json
Dockerfile Modify No npm build, copy binary + static
scripts/build Modify Remove npm build step
scripts/test Modify Just Haskell build + test
scripts/run Modify Updated for Hyperbole serving
README.md Modify Update tech stack description
AGENTS.md Modify Update conventions

Task 1: Add Hyperbole Dependencies

Files: package.yaml, stack.yaml

  • Step 1: Update package.yaml

Replace deps section. Key changes:

  • Add DataKinds, TypeFamilies to default-extensions

  • Shared deps: remove beeline-routing, json-fleece-aeson, json-fleece-core, mtl, optparse-applicative, safe-exceptions, shrubbery, wai-extra; add effectful

  • Library deps: remove cookie, orb; add atomic-css, data-default, effectful-core, hyperbole, string-conversions, text-casing

  • Executable deps: remove optparse-applicative, orb, unix; add effectful, hyperbole

  • Step 2: Run hpack

./hs hpack
  • Step 3: Update stack.yaml

Replace with:

resolver: lts-24.38

packages:
  - .

extra-deps:
  - hyperbole-1.0.0.0
  - atomic-css-0.1.0.0
  - effectful-2.3.0.0
  - effectful-core-2.3.0.0
  - string-conversions-0.4.0.1
  - text-casing-0.1.0.3
  - data-default-0.7.1.2
  • Step 4: Verify dependency resolution
./hs stack build --dry-run 2>&1 | tail -30

Expected: Shows build plan. Adjust resolver or extra-dep versions as needed.

  • Step 5: Commit
git add package.yaml stack.yaml sis-server.cabal
git commit -m "deps: add hyperbole, effectful, atomic-css; remove orb/mithril deps"

Task 2: Strip Aeson from Types, Add Form Types

Files: src/Sis/Types.hs

  • Step 1: Remove all Aeson instances

In src/Sis/Types.hs:

  • Remove Aeson import and all instance A.ToJSON/instance A.FromJSON blocks

  • Remove A.ToJSON, A.FromJSON, A.ToJSONKey, A.FromJSONKey from newtype deriving

  • Remove request types: SignupRequest, LoginRequest, CreateHouseholdRequest, CreateInviteRequest, CreateChoreRequest, UpdateChoreRequest, RecordActivityRequest, ErrorResponse, SeedRequest, AuthResponse, UserPublic

  • Keep all core domain types

  • Step 2: Add Hyperbole form types at end of file

import GHC.Generics (Generic)
import Web.Hyperbole.HyperView.Forms (FromForm (..))

data LoginForm = LoginForm
    { lfEmail :: Text, lfPassword :: Text, lfRemember :: Bool }
    deriving stock (Show, Eq, Generic)
    deriving (FromForm) via GenericForm LoginForm

data SignupForm = SignupForm
    { sfDisplayName :: Text, sfEmail :: Text, sfPassword :: Text
    , sfConfirm :: Text, sfAgree :: Bool }
    deriving stock (Show, Eq, Generic)
    deriving (FromForm) via GenericForm SignupForm

data ChoreFormData = ChoreFormData
    { cfdName :: Text, cfdScheduleType :: Text, cfdStartDate :: Text
    , cfdTimeOfDay :: Maybe Text, cfdPeriod :: Text
    , cfdAssignee :: Text, cfdNotify :: Bool }
    deriving stock (Show, Eq, Generic)
    deriving (FromForm) via GenericForm ChoreFormData

data ActivityFormData = ActivityFormData
    { afdStatus :: Text, afdNote :: Maybe Text, afdNotify :: Bool }
    deriving stock (Show, Eq, Generic)
    deriving (FromForm) via GenericForm ActivityFormData

data HouseholdFormData = HouseholdFormData
    { hfdName :: Text }
    deriving stock (Show, Eq, Generic)
    deriving (FromForm) via GenericForm HouseholdFormData
  • Step 3: Verify Types.hs compiles
./hs stack build 2>&1 | tail -20

Expected: Types.hs compiles. Other modules will fail.

  • Step 4: Commit
git add src/Sis/Types.hs
git commit -m "refactor: strip Aeson instances from Types; add Hyperbole form types"

Task 3: Database Effect

Files: src/Sis/Database.hs (rewrite)

Rewrite src/Sis/Database.hs to define a GADT DB effect with these constructors:

data DB :: Effect where
    FindUserByEmail :: Text -> DB m (Maybe User)
    CreateUser :: Text -> Text -> Text -> DB m UserId
    GetUser :: UserId -> DB m (Maybe User)
    GetUserHouseholds :: UserId -> DB m [Household]
    GetHousehold :: UserId -> Int -> DB m (Maybe Household)
    CreateHousehold :: UserId -> Text -> DB m Household
    GetMembers :: Int -> DB m [Membership]
    GetChores :: Int -> DB m [Chore]
    CreateChore :: Int -> Text -> ChoreAssignee -> Schedule -> Bool -> DB m Chore
    UpdateChore :: Int -> Int -> Text -> ChoreAssignee -> Schedule -> Bool -> DB m Chore
    DeleteChore :: Int -> DB m ()
    GetDashboard :: Int -> Day -> DB m Dashboard
    GenerateOccurrences :: Chore -> DB m ()
    RecordActivity :: Int -> UserId -> ActivityStatus -> Maybe Text -> Bool -> DB m Activity
    GetActivityLog :: Int -> Int -> Int -> DB m ActivityLogPage
    CreateInvite :: Int -> Maybe Text -> DB m Invite
    GetInvites :: Int -> DB m [Invite]
    RevokeInvite :: Int -> DB m ()
    AcceptInvite :: UserId -> Text -> DB m Household
    Seed :: DB m ()

Implement runDB :: SQL.Connection -> Eff (DB : es) a -> Eff es a using interpret, with each constructor running the same SQL queries currently in Server.hs. Export openAndRunDB which opens the connection (with WAL + FK + migrations), runs the effect, and closes. Keep runMigrations almost unchanged.

Add Show + Read instances to Schedule and SchedulePeriod (in Types.hs) for storage as strings. Include helper functions generateOccurrencesIO, generateRecurringDates, hashPasswordIO, generateTokenIO as local helpers.

  • Step 1: Write the rewrite
# The file should be ~400 lines, covering all DB operations
  • Step 2: Update Sis.hs exports

Remove import Sis.Server as X from src/Sis.hs.

  • Step 3: Verify it compiles
./hs stack build 2>&1 | tail -20
  • Step 4: Commit
git add src/Sis/Database.hs src/Sis.hs src/Sis/Types.hs
git commit -m "refactor: convert Database to effectful DB effect"

Task 4: Auth Module Update + Delete Server.hs

Files: src/Sis/Auth.hs (modify), src/Sis/Server.hs (delete)

  • Step 1: Simplify Auth.hs

Remove cookie/session helpers (makeSessionCookie, clearSessionCookie, sessionCookieName). Keep only:

module Sis.Auth (hashPassword, verifyPassword, generateToken) where

Remove HTTP imports. The rest stays the same (password hashing via PBKDF2).

  • Step 2: Delete Server.hs
rm src/Sis/Server.hs
  • Step 3: Commit
git rm src/Sis/Server.hs
git add src/Sis/Auth.hs
git commit -m "refactor: simplify Auth module; remove Orb/WAI Server"

Task 5: Route + Style + Layout Modules

Files: src/Sis/Route.hs (create), src/Sis/Style.hs (create), src/Sis/View/Layout.hs (create)

  • Step 1: Create Route module
module Sis.Route where

import GHC.Generics (Generic)
import Web.Hyperbole.Route

data AppRoute
    = RouteHome | RouteLogin | RouteSignup
    | RouteDashboard | RouteChores | RouteHousehold | RouteActivity
    deriving (Eq, Generic, Show)

instance Route AppRoute where
    baseRoute = Just RouteHome
  • Step 2: Create Style module

Shortcuts for Neo Brutalism CSS classes:

module Sis.Style where

import Data.Text (Text)
import Web.Hyperbole.View

nbBox, nbButton, nbInput, nbLabel, nbBadge, nbFontHeading1, nbFontHeading2, nbContainer, nbList, nbListItem, nbRow :: Mods
nbBox = att "class" "nb-box"
nbButton = att "class" "nb-button"
nbInput = att "class" "nb-input"
nbLabel = att "class" "nb-label"
nbBadge = att "class" "nb-badge"
nbFontHeading1 = att "class" "nb-font-heading1"
nbFontHeading2 = att "class" "nb-font-heading2"
nbContainer = att "class" "nb-container"
nbList = att "class" "nb-box"
nbListItem = att "class" "nb-list-item"
nbRow = att "class" "nb-row"

colorRed, colorYellow, colorGreen :: Text
colorRed = "var(--nb-red)"
colorYellow = "var(--nb-yellow)"
colorGreen = "var(--nb-green)"
  • Step 3: Create Layout module

Create src/Sis/View/Layout.hs with:

  • documentHead :: View DocumentHead () — Neo Brutalism CDN link, custom CSS, scriptEmbed, mobileFriendly, manifest

  • navbar :: User -> [Household] -> View ctx () — "Sis" heading, navigation links (Dashboard/Chores/Household/Activity/Logout)

  • requireAuth :: (Hyperbole :> es, DB :> es) => Eff es User — reads UserSession from Hyperbole session, redirects to login if absent

  • UserSession type with Session instance for cookie-based auth state

  • Step 4: Commit

git add src/Sis/Route.hs src/Sis/Style.hs src/Sis/View/Layout.hs
git commit -m "feat: add Route, Style, and Layout modules"

Task 6: Login & Signup Pages

Files: src/Sis/Page/Login.hs (create), src/Sis/Page/Signup.hs (create)

  • Step 1: Create Login page

LoginPage HyperView with Action = SubmitLogin. On submit, queries DB for user, verifies password, saves UserSession, redirects to dashboard. On failure, re-renders with error. Page function checks for existing session and redirects if logged in.

module Sis.Page.Login (page) where

import Sis.Database
import Sis.Route (AppRoute (..))
import Sis.Style
import Sis.Types
import Sis.View.Layout
import Web.Hyperbole
import Web.Hyperbole.HyperView.Forms
import Web.Hyperbole.Effect.Session (Session (..), saveSession, lookupSession)

data UserSession = UserSession { usUserId :: Int }
    deriving (Generic, FromJSON, ToJSON)
instance Session UserSession where cookiePath = Just "/"

data LoginPage = LoginPage deriving (Generic, ViewId)
instance HyperView LoginPage es where
    data Action LoginPage = SubmitLogin deriving (Generic, ViewAction)
    update SubmitLogin = do
        LoginForm{..} <- formData @LoginForm
        mUser <- FindUserByEmail lfEmail
        case mUser of
            Just u | verifyPassword lfPassword u.userPasswordHash -> do
                saveSession (UserSession (unUserId u.userId))
                redirect (routeUri RouteDashboard)
                pure $ el "Redirecting..."
            _ -> pure $ loginView (Just "Invalid credentials")

The loginView renders: centered box with heading, email input, password input, remember-me checkbox, submit button, signup link. Uses NB classes: nbContainer, nbBox, nbFontHeading1, nbLabel, nbInput, nbButton.

  • Step 2: Create Signup page

Same pattern. SignupPage HyperView. Validates password length (≥8), password match, email uniqueness. Creates user, saves session, redirects.

  • Step 3: Commit
git add src/Sis/Page/Login.hs src/Sis/Page/Signup.hs
git commit -m "feat: add Login and Signup Hyperbole pages"

Task 7: Dashboard Page + ActivityForm

Files: src/Sis/Page/Dashboard.hs (create), src/Sis/View/ActivityForm.hs (create)

  • Step 1: Create Dashboard page

DashboardPage HyperView with Action = RefreshDashboard | CheckOff OccurrenceId. On refresh, loads dashboard from DB for the user's active household. Stats tiles show overdue/dueToday/doneThisWeek counts with color-coded borders.

Due items list: each has chore name, assignee, status badge (OVERDUE/DUE), "Check Off" button. Clicking "Check Off" opens the ActivityForm HyperView inline (replacing the button row).

Completed items: read-only list of today's activities with user name, chore name, time, note.

The page function (page) gets the user from requireAuth, determines the active household, and initializes the dashboard.

  • Step 2: Create ActivityForm HyperView
data ActivityForm = ActivityForm OccurrenceId deriving (Generic, ViewId)
instance HyperView ActivityForm es where
    data Action ActivityForm = SubmitActivity | CancelActivity
        deriving (Generic, ViewAction)
    update SubmitActivity = do
        ActivityForm oid <- viewId
        ActivityFormData{..} <- formData @ActivityFormData
        let status = if afdStatus == "skipped" then ActivitySkipped else ActivityCompleted
        -- Get user from session context (passed via view state or page-level data)
        pure $ el "Recorded!" -- pushEvent to refresh dashboard
    update CancelActivity = pure none

The form view: status dropdown (completed/skipped), note textarea, notify checkbox, Save/Cancel buttons.

  • Step 3: Commit
git add src/Sis/Page/Dashboard.hs src/Sis/View/ActivityForm.hs
git commit -m "feat: add Dashboard page with ActivityForm HyperView"

Task 8: Chores Page + ChoreForm

Files: src/Sis/Page/Chores.hs (create), src/Sis/View/ChoreForm.hs (create)

  • Step 1: Create Chores page

ChoresPage HyperView with Action = RefreshChores | DeleteChore ChoreId | ToggleCreate | ToggleEdit ChoreId. View state tracks whether the create/edit form is active.

Chore list: each row shows name, schedule badge (recurring/one-off/sometime), schedule description, Edit/Delete buttons. Delete shows inline confirmation.

"New Chore" button toggles the ChoreForm HyperView inline at the top of the list.

  • Step 2: Create ChoreForm HyperView

ChoreForm with Action = SubmitChore | CancelChore. Form fields:

  • Name (text input)
  • Schedule type (select: recurring, one-off, sometime)
  • Start date (date input)
  • Time of day (time input, optional)
  • Period (select: daily/weekly/monthly, shown if recurring)
  • Assignee (select: anyone)
  • Notify on due (checkbox)

On submit, parses form data into Schedule, ChoreAssignee. Calls CreateChore or UpdateChore. Cancels by setting parent view state to hide form.

  • Step 3: Commit
git add src/Sis/Page/Chores.hs src/Sis/View/ChoreForm.hs
git commit -m "feat: add Chores page with ChoreForm HyperView"

Task 9: Household & Activity Pages

Files: src/Sis/Page/Household.hs (create), src/Sis/Page/Activity.hs (create)

  • Step 1: Household page

HouseholdPage HyperView with Action = CreateInvite | RevokeInvite InviteId | CreateHousehold. Shows member list with display name, email, role badge, initials avatar. If user has no households: show "Create Your Household" form. Owner actions: create invite (shows generated code), revoke invites.

  • Step 2: Activity Log page

ActivityLog HyperView with Action = GoToPage Int. View state tracks current page number. Shows paginated entries (20/page): status badge (COMPLETED/SKIPPED), user name, chore name, date, time, optional note. Previous/Next buttons with page indicator.

  • Step 3: Commit
git add src/Sis/Page/Household.hs src/Sis/Page/Activity.hs
git commit -m "feat: add Household and Activity Log pages"

Task 10: Main.hs — Wire Everything Together

Files: app/Main.hs (rewrite)

  • Step 1: Create app entry point
module Main where

import Effectful
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Middleware.Static qualified as Static

import Sis.Database (openAndRunDB, runMigrations)
import Sis.Route
import Sis.View.Layout

main :: IO ()
main = do
    let port = 8080
    conn <- see Sis.Database openDatabase
    Warp.run port $
        Static.staticPolicy (Static.addBase "frontend/static") $
            liveAppWith
                (ServerOptions (document documentHead) defaultError defaultParseRequestBodyOptions)
                (runDB conn $ routeRequest router)

router :: (Hyperbole :> es, DB :> es) => AppRoute -> Eff es Response
router RouteHome = redirect (routeUri RouteDashboard)
router RouteLogin = runPage Sis.Page.Login.page
router RouteSignup = runPage Sis.Page.Signup.page
router RouteDashboard = runPage Sis.Page.Dashboard.page
router RouteChores = runPage Sis.Page.Chores.page
router RouteHousehold = runPage Sis.Page.Household.page
router RouteActivity = runPage Sis.Page.Activity.page
  • Step 2: Move static files
mkdir -p frontend/static
cp frontend/public/style.css frontend/static/style.css
cp frontend/public/manifest.json frontend/static/manifest.json

Update style.css: remove #app padding, set body margin to 0.

  • Step 3: Build and fix compilation errors
./hs stack build 2>&1 | tail -40

Fix import/type/extension errors iteratively.

  • Step 4: Commit
git add app/Main.hs frontend/static/
git commit -m "feat: wire up Hyperbole app entry point with all pages"

Task 11: Cleanup — Delete TypeScript, Update Scripts, Docker, Docs

Files: Multiple deletes and modifies

  • Step 1: Delete frontend TypeScript
rm -rf frontend/src frontend/index.html frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/node_modules frontend/public frontend/dist
  • Step 2: Update scripts/build

Remove npm steps. Just fourmolu, hlint, stack build.

  • Step 3: Update scripts/test

Just hlint + stack test.

  • Step 4: Update scripts/run

Simplify: use stack exec sis-server directly in Docker.

  • Step 5: Update Dockerfile

Remove ADD frontend/dist. Just add binary and frontend/static. No npm build.

  • Step 6: Update test/Spec.hs

Replace with a simple "compiles" test.

  • Step 7: Update README.md

Replace frontend/backend sections. Describe Hyperbole stack, note zero application JavaScript.

  • Step 8: Update AGENTS.md

Replace frontend conventions section:

## Frontend Conventions

- **Framework:** [Hyperbole](https://github.com/seanhess/hyperbole) — Haskell serverside web framework
- **CSS:** [Neo Brutalism](https://unpkg.com/neobrutalismcss@latest) CDN + `frontend/static/style.css`
- **Build:** No npm/build step for frontend. All pages rendered in Haskell.
- **Interactive components:** HyperViews with typed Actions and server-side updates

Update project structure to show new module layout. Remove frontend/src/, add Sis/Page/ and Sis/View/ directories.

  • Step 9: Commit
git add -A
git commit -m "chore: cleanup TypeScript, update scripts, Dockerfile, README, AGENTS"

Task 12: Verify with Playwright

Files: None (verification only)

  • Step 1: Start the server
./hs stack build && ./scripts/build
./scripts/run &
sleep 5
  • Step 2: Verify key flows with Playwright
npx playwright test --project=chromium 2>&1

Or manually verify: login → dashboard → create chore → check off → view activity log.

  • Step 3: Fix any issues found

Iterate on compilation/runtime bugs discovered during verification.

  • Step 4: Commit fixes
git add -A
git commit -m "fix: address issues found during verification"