Implementation Plan
A self-hosted, lightweight workspace tool. V1 scope is a dynamic data grid with typed columns, per-workspace versioning, and simple session-based auth. No frameworks. No dependencies beyond what is strictly needed.
What we are building
A personal or small-team workspace tool, self-hosted on any PHP-capable server. Users log in and see a sidebar listing their workspaces. Each workspace is a dynamic table where columns are typed — either free text or a status label with user-defined options and colors. Workspaces are saved explicitly, with a background autosave every ten minutes as a safety net.
Scope boundaries
| Feature | V1 | Deferred |
|---|---|---|
| Grid workspace | Included | |
| Text workspace | V2 | |
| Checklist workspace | V2 | |
| Email/password auth | Included | |
| User registration UI | V2 | |
| Authorized users per workspace | Stubbed | |
| Full snapshot versioning | Included | |
| Rollback UI | V2 | |
| Flat-file storage | Included | |
| SQLite / Supabase storage | Interface ready | |
| Idle session timeout | V2 option | |
| Version control | Included |
Technology Stack
No frontend framework. No build step. Every choice is justified by simplicity, familiarity, and self-hostability.
Why no frontend framework?
The interactivity requirements for V1 — editable cells, dropdowns, column config popovers, an unsaved-changes flag — are well within Alpine's scope. Adding React or Vue would introduce a build step, a node_modules directory, and a component lifecycle to reason about, none of which are warranted here. Alpine keeps reactivity local and declarative without changing the HTML mental model.
Why HTMX alongside Alpine?
Alpine manages UI state. HTMX manages server communication. They have no overlap. HTMX's hx-post and hx-get replace fetch calls for loading and saving workspaces. Alpine handles everything that never needs to touch the server until save time.
File Structure
Webroot is /public. All data, config, and source lives outside it. PHP sessions handle auth; no .htaccess trickery needed for security if the server is configured correctly.
data/ must be outside webroot or protected with a server-level deny rule. It contains plaintext user credentials and workspace data. On Apache, a .htaccess with Deny from all is sufficient if moving it outside webroot is not possible.
Storage Layer
Storage is abstracted behind a PHP interface from day one. The V1 implementation is flat JSON files. Swapping to SQLite or Supabase later requires only writing a new class — no changes to endpoints or templates.
The interface
interface WorkspaceStorage {
public function list(string $userId): array;
public function load(string $workspaceId): array;
public function save(string $workspaceId, array $data): void;
public function create(string $ownerId, string $name): array;
public function delete(string $workspaceId): void;
}
FlatFileStorage — V1
Each workspace lives in its own subdirectory under data/workspaces/. The canonical state is always current.json. On every save, the previous current.json is copied into snapshots/ with a timestamped filename before being overwritten.
data/workspaces/
ws_001/
current.json
snapshots/
2026-03-01T10-00-00Z_v1.json
2026-03-01T14-32-00Z_v2.json
Users file
A single data/users.json holds all user records. In V1, this file is hand-edited by the admin. No registration endpoint exists. The structure is:
[
{
"id": "usr_001",
"email": "alice@example.com",
"password_hash": "$2y$12$...",
"display_name": "Alice"
}
]
config.php
A single config file at the project root sets the data path and the active storage driver. Switching drivers in the future is a one-line change here.
define('DATA_PATH', __DIR__ . '/data');
define('STORAGE_DRIVER', 'flatfile'); // 'sqlite' | 'supabase'
Data Model
Every workspace is a single JSON document. The schema is designed to be human-readable and to support future workspace types beyond grid.
Full workspace document
{
"workspace_id": "ws_001",
"workspace_name": "Project Tracker",
"workspace_owner": "usr_001",
"authorized_users": ["usr_002"],
"timestamp_created": "2026-03-01T10:00:00Z",
"timestamp_lastedited": "2026-03-01T14:32:00Z",
"workspace_type": "grid",
"version": 3,
"schema": {
"columns": [
{
"id": "col_1",
"name": "Task",
"type": "text"
},
{
"id": "col_2",
"name": "Status",
"type": "status",
"options": [
{ "label": "To Do", "color": "#e2e8f0" },
{ "label": "In Progress", "color": "#fef08a" },
{ "label": "Done", "color": "#86efac" }
]
},
{
"id": "col_3",
"name": "Priority",
"type": "status",
"options": [
{ "label": "Low", "color": "#dbeafe" },
{ "label": "High", "color": "#fee2e2" }
]
}
]
},
"rows": [
{
"id": "row_1",
"cells": {
"col_1": "Write documentation",
"col_2": "In Progress",
"col_3": "High"
}
},
{
"id": "row_2",
"cells": {
"col_1": "Deploy to production",
"col_2": "To Do",
"col_3": "High"
}
}
]
}
Key decisions
| Decision | Rationale |
|---|---|
| schema.columns not columns | When text and checklist workspace types are added, they will have different schema shapes. Namespacing under schema keeps the top-level clean. |
| cells keyed by column id | Adding or deleting a column does not shift row data. Cells for deleted columns are orphaned but harmless; easily pruned on save. |
| status options on column | Options are defined once per column, not duplicated per cell. A cell stores only the chosen label string. |
| version integer | Increments on every save. Paired with the timestamped snapshot filename, this gives an unambiguous history. |
| authorized_users array | Stored in V1, not enforced in V1. Access control logic reads this field when implemented in V2. |
Authentication
Simple, stateful, and secure enough for a self-hosted personal tool. Email and bcrypt-hashed password. PHP sessions. No OAuth, no tokens, no registration UI in V1.
Login flow
The user visits /login.php. On POST, api/auth.php reads data/users.json, finds the matching email, and runs password_verify() against the stored bcrypt hash. On success, the user ID and email are written to $_SESSION and the user is redirected to /index.php. On failure, an error message is returned.
Session behavior
| Behavior | Implementation |
|---|---|
| Session start | session_start() at the top of every protected page and endpoint |
| Auth check | Every protected page checks $_SESSION['user_id'] and redirects to /login.php if absent |
| No expiry | Session lasts until the browser closes or the user logs out. No gc_maxlifetime override needed. |
| Logout | logout.php calls session_destroy() and redirects to /login.php |
Adding users (V1)
Users are hand-written into data/users.json. The password hash is generated with PHP's password_hash($password, PASSWORD_BCRYPT). A tiny CLI helper script can be provided for this: php scripts/add-user.php alice@example.com mypassword.
Auth::createUser() method that the CLI script uses. No endpoint changes needed.
App Layout
Two-panel shell. Sidebar always visible after login. Main panel loads workspaces via HTMX without full page reloads.
Page structure
+------------------+------------------------------------+
| SIDEBAR | MAIN PANEL |
| | |
| CoWork | [ Workspace Name — editable ] |
| ───────────── | |
| Project Tracker | col1 ▾ col2 ▾ col3 ▾ [+col] |
| Design Notes | ────────────────────────────── |
| Sprint Board | text ● Done ● High [+row] |
| | text ● To Do ● Low [+row] |
| + Workspace | |
+------------------+------------------------------------+
Sidebar behaviors
| Element | Behavior |
|---|---|
| Workspace list item | Clicking loads the workspace into the main panel via hx-get="/api/workspace.php?id=ws_001" targeting the main panel div |
| Workspace name (sidebar) | Click to edit inline. On blur or Enter, fires a save to update the name. Alpine-driven. |
| + Workspace button | Posts to /api/workspace.php (create action), returns the new workspace HTML and a refreshed sidebar list |
| Active state | Currently loaded workspace is highlighted in the sidebar |
| Log Out | Link at the bottom of the sidebar to logout.php |
Default workspace naming
When a new workspace is created without a name, the backend counts existing workspaces owned by the user and assigns the next available name: workspace 1, workspace 2, etc. The name is immediately editable in the main panel header.
Workspace Grid
A dynamic table with typed columns. Column and row count can be changed at any time with SVG icon buttons. Two column types: free text and status/label.
Grid rendering
The workspace is rendered as an HTML <table> from the workspace JSON. The Alpine component on the table wrapper holds the full workspace state object. All mutations (add row, delete row, edit cell, etc.) update this state object. Nothing is persisted until save.
Column header
Each <th> contains the column name (editable inline), a type indicator, and a small gear/settings icon that opens a column config popover. The popover lets the user rename the column, change its type, and — if type is status — edit the label options and their colors. A delete icon removes the column (with confirmation).
Column types
| Type | Cell behavior | Column config |
|---|---|---|
| text | Contenteditable div, freeform input | Name only |
| status | Colored pill showing current label; click opens a dropdown of options defined on the column | Name + option list (add/remove/rename options, pick color per option) |
Add / delete rows and columns
An SVG + icon at the end of the header row adds a new column (defaults to text type). An SVG + icon at the bottom of the table adds a new row. Each row has a delete icon on the left (visible on hover). Each column header has a delete icon in its config popover. Row and column IDs are generated client-side as row_<timestamp> and col_<timestamp>.
Alpine state shape
Alpine.data('workspace', () => ({
meta: { id, name, version, ... },
schema: { columns: [...] },
rows: [...],
dirty: false, // true when unsaved changes exist
saving: false, // true during save request
lastSaved: null, // timestamp of last successful save
addRow() { ... },
deleteRow(id) { ... },
addColumn() { ... },
deleteColumn(id) { ... },
updateCell(rowId, colId, value) { ... },
markDirty() { this.dirty = true; },
save() { ... } // posts to /api/workspace.php
}))
Saving & Versioning
Explicit save with a background autosave safety net every ten minutes. Unsaved-change detection with a leave-page warning. Full snapshots on every save.
Save mechanisms
| Trigger | Behavior |
|---|---|
| Save button | Visible and active when dirty === true. Posts workspace JSON to /api/workspace.php. On success, dirty = false, version++, lastSaved updated. |
| Autosave | setInterval in Alpine every 600,000ms (10 min). Only fires if dirty === true. Silent — no UI confirmation. Same endpoint as manual save. |
| Leave-page warning | window.beforeunload event listener. Returns a warning string if dirty === true. Browser shows native "unsaved changes" dialog. |
Server-side save process
When api/workspace.php receives a save POST, it does the following in order: validate the session, validate the payload, read the current current.json, copy it to snapshots/<timestamp>_v<n>.json, increment the version in the incoming payload, write the new current.json, return a success response with the new version number and timestamp.
Snapshot naming
snapshots/
2026-03-01T10-00-00Z_v1.json
2026-03-01T14-32-00Z_v2.json
2026-03-01T22-15-44Z_v3.json
The timestamp uses hyphens instead of colons to be filesystem-safe on all platforms. The version suffix is redundant with the timestamp but makes it human-readable at a glance. Rollback UI in V2 reads this directory, sorts by timestamp, and presents the list.
Save button states
| State | Appearance |
|---|---|
| No changes | Grayed out, disabled |
| Unsaved changes | Active, accent color |
| Saving in progress | Spinner, disabled |
| Save successful | Brief "Saved" confirmation, then returns to grayed-out |
Build Order
Each phase produces something that runs. No phase should require the next to be testable.
Create the full directory structure. Write config.php. Write the WorkspaceStorage interface and the stub FlatFileStorage class. Create a placeholder data/users.json with one hand-written user.
Build login.php (template + POST handler via api/auth.php). Build Auth.php with session helpers. Build logout.php. Every subsequent page can now be protected. Test with the hand-written user.
Build layout.php with the two-panel shell. Build sidebar.php partial. Wire up + Workspace creation. Workspace list loads from FlatFileStorage::list(). Main panel shows a placeholder. Sidebar navigation works.
Build workspace-grid.php template. Build the Alpine workspace data component. Render columns and rows from JSON. Implement contenteditable text cells. Implement add/delete row and column with SVG icons. Dirty flag tracking.
Add column type selector to column config popover. Build the status cell dropdown component in Alpine. Build the option editor (add/remove options, label text, color picker). Wire up cell value selection.
Complete FlatFileStorage::save() with snapshot logic. Wire up save button via HTMX. Add setInterval autosave in Alpine. Add beforeunload leave-page warning. Test version increments and snapshot files.
Default workspace naming counter. Inline workspace rename in sidebar and main panel header. Empty state for a workspace with no rows. Column delete confirmation. Handle orphaned cell keys on column delete. Review all session-protection paths.
V2 Considerations
Decisions made in V1 that are specifically designed to make these features straightforward to add later.
| Feature | What V1 does to prepare for it |
|---|---|
| User registration | Auth::createUser() method exists and is used by the CLI script. A registration form calls the same method. |
| Authorized users per workspace | authorized_users array is stored in every workspace document. Access control checks in workspace.php are stubbed as comments. |
| Rollback UI | Snapshot directory exists and is populated on every save. V2 adds an endpoint that reads the directory and a UI to select and restore a snapshot. |
| SQLite storage | WorkspaceStorage interface means writing SQLiteStorage.php and changing one line in config.php. |
| Supabase storage | Same interface. SupabaseStorage.php hits Supabase's REST API. No other changes. |
| Text workspace type | workspace_type field exists in every document. The main panel checks this field to decide which template to render. A workspace-text.php template is added. |
| Checklist workspace type | Same pattern as text workspace. |
| Idle timeout / Remember me | Session logic is centralized in Auth.php. Adding a last_activity check or a long-lived cookie is isolated to that class. |
| Versioning | Rollbacks to prior snapshots. |