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

628 lines
21 KiB
Markdown

# 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"
```