diff --git a/docs/plans/2026-07-15-hyperbole-port.md b/docs/plans/2026-07-15-hyperbole-port.md new file mode 100644 index 0000000..22fb444 --- /dev/null +++ b/docs/plans/2026-07-15-hyperbole-port.md @@ -0,0 +1,627 @@ +# 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" +```