docs: design spec for porting Sis to Hyperbole
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user