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,TypeFamiliesto default-extensions -
Shared deps: remove
beeline-routing,json-fleece-aeson,json-fleece-core,mtl,optparse-applicative,safe-exceptions,shrubbery,wai-extra; addeffectful -
Library deps: remove
cookie,orb; addatomic-css,data-default,effectful-core,hyperbole,string-conversions,text-casing -
Executable deps: remove
optparse-applicative,orb,unix; addeffectful,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.FromJSONblocks -
Remove
A.ToJSON,A.FromJSON,A.ToJSONKey,A.FromJSONKeyfrom 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— readsUserSessionfrom Hyperbole session, redirects to login if absent -
UserSessiontype withSessioninstance 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"