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