docs: design spec for porting Sis to Hyperbole

This commit is contained in:
2026-07-15 22:17:50 -04:00
parent 194df4d2fe
commit de4b48c8e5
@@ -0,0 +1,292 @@
# Hyperbole Port Design
**Date:** 2026-07-15
**Status:** Draft
## Motivation
The current Sis architecture uses a Haskell JSON API backend (Orb/WAI) with a
TypeScript/Mithril.js SPA frontend. The JavaScript framework has issues with
routing and basic functionality. We want to eliminate JavaScript entirely by
porting to [Hyperbole](https://github.com/seanhess/hyperbole), a Haskell
serverside web framework inspired by HTMX, Elm, and Phoenix LiveView.
After the port, there will be zero application TypeScript/JavaScript in the
source code. Only Hyperbole's client runtime (~40KB gzipped) runs in the
browser, providing WebSocket connectivity and VirtualDOM patching.
## Key Decisions
- **API:** Remove the JSON API entirely. All interactivity happens over
Hyperbole's WebSocket channel with serverside-rendered HTML.
- **Build system:** Keep Stack. Add `hyperbole` and its dependencies to
`package.yaml` and `stack.yaml`.
- **Database:** Full effectful effect system. Create a custom `DB` effect
that wraps all SQLite operations, replacing the current raw
`SQL.Connection` passing.
- **Auth:** Use Hyperbole's built-in session mechanism (cookie-based, stored
in memory). Replaces the current custom cookie/SQLite session system.
- **CSS:** Keep Neo Brutalism CDN + thin custom CSS. Use Hyperbole's
`atomic-css` for inline styles where needed, but primarily use Neo
Brutalism class names.
- **UI:** Match the current UI look. No visual redesign. Make UI decisions
independently when questions arise, leaning on Neo Brutalism defaults.
## Architecture
Before:
```
Browser ←[HTTP GET]→ WAI static serving (SPA index.html + JS)
Browser ←[fetch JSON]→ WAI /api/* routes
Mithril SPA: routing, state, rendering all in TypeScript
```
After:
```
Browser ←[HTTP/WebSocket]→ Warp + Hyperbole
- Initial page: full HTML rendered serverside
- Interactions: WebSocket with VirtualDOM diffs
- All routing, state, rendering in Haskell
```
Module structure:
```
sis/
├── app/Main.hs # Hyperbole app entry, Warp setup, route dispatch
├── src/
│ ├── Sis.hs # Top-level re-exports
│ ├── Sis/Route.hs # Route ADT with Route instance
│ ├── Sis/Types.hs # Core domain types (Aeson instances removed)
│ ├── Sis/Database.hs # effectful DB effect, SQLite operations
│ ├── Sis/Auth.hs # Hyperbole session-based auth + password hashing
│ ├── Sis/Page/
│ │ ├── Login.hs # Login page
│ │ ├── Signup.hs # Signup page
│ │ ├── Dashboard.hs # Dashboard with stats + due/completed items
│ │ ├── Chores.hs # Chore list + create/edit form
│ │ ├── Household.hs # Members, invites, household management
│ │ └── Activity.hs # Activity log with pagination
│ ├── Sis/View/
│ │ ├── Layout.hs # Shell: document head, navbar, page wrapper
│ │ ├── ChoreForm.hs # Create/edit chore form (HyperView)
│ │ ├── ActivityModal.hs # Record activity form (HyperView)
│ │ └── Field.hs # Reusable form field helpers
│ └── Sis/Style.hs # CSS helpers for Neo Brutalism classes
├── frontend/
│ └── static/
│ ├── style.css # Thin custom CSS (nav, bg, animations)
│ └── manifest.json # PWA manifest (unchanged)
├── package.yaml # Updated deps (hyperbole, atomic-css, effectful)
├── stack.yaml # Updated resolver + extra-deps
└── Dockerfile # Updated (no npm build step)
```
Deleted:
- `frontend/src/` — all TypeScript code
- `src/Sis/Server.hs` — Orb/WAI routing replaced by Hyperbole pages
- Dependencies: orb, beeline-routing, shrubbery, json-fleece-aeson, json-fleece-core
## Routes
Flat route structure mapping to the current pages:
```haskell
data AppRoute
= RouteHome -- redirects based on auth status
| RouteLogin
| RouteSignup
| RouteDashboard
| RouteChores
| RouteHousehold
| RouteActivity
```
The `Route` typeclass generates URLs from constructor names: `/login`,
`/dashboard`, etc. No dynamic route segments needed — all interactions
happen within pages via HyperViews, not separate detail pages.
Router dispatches each route to a page handler:
```haskell
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
```
Auth checking happens inside each protected page via `requireAuth`, which
reads user ID from Hyperbole's session. If not authenticated, redirects to
login.
## Pages
### Login Page (`Sis.Page.Login`)
- **`LoginForm`** HyperView with email, password, remember-me fields
- `Action = SubmitLogin Text Text Bool`
- On success: stores user ID in Hyperbole session, redirects to dashboard
- On failure: re-renders form with error message
- Navbar hidden on this page
- Link to signup via `route RouteSignup`
### Signup Page (`Sis.Page.Signup`)
- **`SignupForm`** HyperView with display name, email, password, confirm, agree-terms
- `Action = SubmitSignup Text Text Text Text Bool`
- Validates (password length, match, email uniqueness), creates user, creates session
- Link to login via `route RouteLogin`
### Dashboard (`Sis.Page.Dashboard`)
- **Single `DashboardPage` HyperView** wrapping all dashboard content
(stats tiles, due items, completed items). A single HyperView ensures
consistency when state changes across sections.
- `Action = RecordActivity OccurrenceId RecordActivityRequest | Refresh`
- Stat tiles: overdue count (red), due today (yellow), done this week (green)
- Due items list: each item shows chore name, assignee, status badge, and
"Check Off" button
- Clicking "Check Off" replaces the item row with an inline record-activity
form (status dropdown, optional note, notify checkbox, Save/Cancel
buttons)
- Completed items: read-only list of today's activities
- Link to full activity log
### Chores Page (`Sis.Page.Chores`)
- **`ChoreList` HyperView** — list of all chores for the household
- `Action ChoreList = DeleteChore ChoreId | StartCreate | StartEdit ChoreId`
- Each chore: name, schedule badge, schedule description, Edit/Delete buttons
- "New Chore" button inserts a **`ChoreForm` HyperView** inline
- `ChoreForm` actions: `Submit {fields} | Cancel`
- On submit, sends `pushEvent` to `ChoreList` to refresh
- Delete asks for confirmation via inline confirmation state in the HyperView
(no JS `confirm()`)
### Household Page (`Sis.Page.Household`)
- **`HouseholdPage` HyperView** — member list, invite management
- `Action = CreateInvite | RevokeInvite InviteId | CreateHousehold Text`
- Members list: avatar initials, name, email, role badge
- If user has no households: show "Create Your Household" form
- Owner-only actions: create invite link (shows generated code), revoke invites
- Invite codes displayed as `/invite/<code>` text
### Activity Log (`Sis.Page.Activity`)
- **`ActivityLog` HyperView** with pagination state
- `Action = GoToPage Int`
- Each entry: status badge, user name, chore name, date, time, optional note
- Previous/Next pagination buttons with current page indicator
- 20 entries per page
## Domain Types
`Sis/Types.hs` keeps all current types but **removes all Aeson instances**
(`ToJSON`, `FromJSON`, `ToJSONKey`, `FromJSONKey`). Types become pure
Haskell records and ADTs. New form data types will be added for Hyperbole
form handling (simple records with field names matching form inputs).
## Database Effect
A custom `effectful` effect `DB` replaces raw `SQL.Connection` passing and
direct SQL queries in route handlers:
```haskell
data DB :: Effect where
-- Auth
FindUserByEmail :: Text -> DB m (Maybe User)
CreateUser :: Text -> Text -> Text -> DB m UserId
-- Households
GetUserHouseholds :: UserId -> DB m [Household]
GetHousehold :: UserId -> HouseholdId -> DB m (Maybe Household)
CreateHousehold :: UserId -> Text -> DB m Household
GetMembers :: HouseholdId -> DB m [Membership]
-- Chores
GetChores :: HouseholdId -> DB m [Chore]
CreateChore :: HouseholdId -> CreateChoreRequest -> DB m Chore
UpdateChore :: ChoreId -> UpdateChoreRequest -> DB m Chore
DeleteChore :: ChoreId -> DB m ()
-- Dashboard & Activities
GetDashboard :: HouseholdId -> Day -> DB m Dashboard
GenerateOccurrences :: Chore -> DB m ()
RecordActivity :: OccurrenceId -> UserId -> RecordActivityRequest -> DB m Activity
GetActivityLog :: HouseholdId -> Int -> Int -> DB m ActivityLogPage
-- Invites
CreateInvite :: HouseholdId -> Maybe Text -> DB m Invite
GetInvites :: HouseholdId -> DB m [Invite]
RevokeInvite :: InviteId -> DB m ()
AcceptInvite :: UserId -> Text -> DB m Household
-- Seed
Seed :: DB m ()
```
The handler `runDB :: SQL.Connection -> Eff (DB : es) a -> IO (Eff es a)`
runs all DB operations against SQLite.
## Auth
Hyperbole's built-in `Session` effect stores per-session data keyed by
cookie:
- **Login:** Validate credentials, store `userId` in session
- **requireAuth:** Read `userId` from session, redirect to login if absent
- **Logout:** Delete session data
- **Password hashing:** Keep `Sis/Auth.hs` using `crypton`, same as current
## CSS & Styling
Neo Brutalism CSS loaded via CDN `<link>` in the document head. Thin custom
CSS (`frontend/static/style.css`) for:
- CSS variables (colors: red, yellow, green, orange)
- Body background (dot pattern)
- Navbar styling (border, shadow, layout, responsive)
- Modal overlay animation (fade-in)
- List item dividers
- Responsive breakpoints
Hyperbole views use `atomic-css` combinators for any additional inline
styles. Neo Brutalism class names set via attributes where needed.
## Implementation Phases
### Phase 1: Scaffold
- Add `hyperbole`, `atomic-css`, `effectful` to `package.yaml` and `stack.yaml`
- Create `app/Main.hs` with Hyperbole `main`, route definitions, document head
- Strip Aeson instances from `Types.hs`
- Verify a simple page renders
### Phase 2: Auth + Login/Signup
- Create `Sis/Database.hs` with `DB` effect and SQLite handler
- Implement Hyperbole session-based auth in `Sis/Auth.hs`
- Build Login and Signup pages
- Add `requireAuth` pattern
### Phase 3: Dashboard
- Build Dashboard page with stats, due items, completed items
- Implement record-activity workflow as inline form
### Phase 4: Chores
- Build Chore list with create/edit/delete
- Build ChoreForm as nested HyperView
### Phase 5: Household + Activity Log
- Build Household page with members, invites
- Build Activity Log page with pagination
### Phase 6: Cleanup
- Delete `frontend/src/` (all TypeScript)
- Remove Orb, beeline, shrubbery, json-fleece deps
- Update scripts, Dockerfile, README.md, AGENTS.md
## Tests
Current Haskell tests in `test/Spec.hs` test the API via HTTP. After the
port:
- DB effect operations will be testable in isolation with a test SQLite
connection
- Page rendering can be tested by running Hyperbole pages and checking
output
- Playwright tests (per AGENTS.md agent autonomy) verify end-to-end behavior