# 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** ```bash ./hs hpack ``` - [ ] **Step 3: Update stack.yaml** Replace with: ```yaml 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** ```bash ./hs stack build --dry-run 2>&1 | tail -30 ``` Expected: Shows build plan. Adjust resolver or extra-dep versions as needed. - [ ] **Step 5: Commit** ```bash 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** ```haskell 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** ```bash ./hs stack build 2>&1 | tail -20 ``` Expected: Types.hs compiles. Other modules will fail. - [ ] **Step 4: Commit** ```bash 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: ```haskell 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** ```bash # 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** ```bash ./hs stack build 2>&1 | tail -20 ``` - [ ] **Step 4: Commit** ```bash 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: ```haskell module Sis.Auth (hashPassword, verifyPassword, generateToken) where ``` Remove HTTP imports. The rest stays the same (password hashing via PBKDF2). - [ ] **Step 2: Delete Server.hs** ```bash rm src/Sis/Server.hs ``` - [ ] **Step 3: Commit** ```bash 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** ```haskell 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: ```haskell 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** ```bash 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. ```haskell 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** ```bash 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** ```haskell 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** ```bash 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** ```bash 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** ```bash 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** ```haskell 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** ```bash 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** ```bash ./hs stack build 2>&1 | tail -40 ``` Fix import/type/extension errors iteratively. - [ ] **Step 4: Commit** ```bash 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** ```bash 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: ```markdown ## 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** ```bash 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** ```bash ./hs stack build && ./scripts/build ./scripts/run & sleep 5 ``` - [ ] **Step 2: Verify key flows with Playwright** ```bash 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** ```bash git add -A git commit -m "fix: address issues found during verification" ```