This is the authoritative reference for Hadoku Task Manager's system design, architectural decisions, and strategic direction. All major technical decisions are documented here.
Audience: Developers integrating, extending, or understanding the system architecture.
Hadoku Task Manager uses the Universal Adapter Pattern - a framework-agnostic architecture that separates pure business logic from framework-specific routing.
- ✅ Framework Agnostic - Handlers work with Express, Hono, Cloudflare Workers, etc.
- ✅ Deployment Flexibility - Deploy to Workers (KV) or self-hosted (filesystem)
- ✅ Pure Business Logic - No framework coupling in core operations
- ✅ Single Source of Truth - Types exported via npm package
// Pure handlers (business logic)
export async function createTask(storage: Storage, auth: AuthContext, input: CreateTaskInput) {
// No Express, no Hono, no framework-specific code
const tasks = await storage.getTasks(auth.userType)
const newTask = { id: generateULID(), ...input }
await storage.saveTasks(auth.userType, [...tasks.tasks, newTask])
return newTask
}
// Parent app provides storage implementation
const storage = createKVStorage(env.TASK_KV) // or filesystem, database, etc.
// Use with any framework
const result = await TaskHandlers.createTask(storage, auth, input)Server is the source of truth. When syncing data, API data always overwrites localStorage.
// On page load, syncFromApi() fetches server data and overwrites localStorage
await syncFromApi() // API data → localStorage (no timestamp comparison)Why this is safer than timestamp comparison:
- ✅ Corrupted localStorage won't overwrite good server data
- ✅ Empty localStorage won't wipe server data
- ✅ Stale localStorage won't resurrect deleted data
- ✅ Simple, predictable behavior
Trade-off: If a user reloads during a pending background sync, the un-synced local change may be lost. This is rare and acceptable.
On page load, values are determined in this priority:
// entry.tsx
const userType = storedUserType || props.userType || urlParams.userType || 'public'
const sessionId = storedSessionId || props.sessionId || 'public-session'Priority (highest to lowest):
- localStorage - Survives auth, device-specific (e.g.,
currentUserType: 'friend') - Props - From parent app (e.g., registry.json defaults)
- URL params - Fallback for testing
- Default -
'public'/'public-session'
Why localStorage first: After key validation, the authenticated userType is stored in localStorage. On reload, this must override the default 'public' from the parent's registry.json.
┌─────────────────────────────────────────────────────────────────────┐
│ KEY VALIDATION FLOW │
└─────────────────────────────────────────────────────────────────────┘
User enters key in Settings
│
▼
POST /task/api/validate-key
Headers: { X-User-Key: "user-key" }
│
▼ (if valid)
POST /task/api/session/create
Headers: { X-User-Key: "user-key" }
Response: { sessionId: "abc123", userType: "friend" }
│
▼
Store in localStorage:
- currentSessionId: "abc123"
- currentUserType: "friend"
│
▼
Page reload (URL cleaned of ?key= param)
│
▼
entry.tsx reads localStorage → userType='friend', sessionId='abc123'
│
▼
createApi('friend', 'abc123') → API with syncFromApi()
│
▼
syncFromApi() → fetches boards from server → updates localStorage
Server sessions may expire (e.g., KV TTL exceeded after 24+ hours). When this happens, the server treats the user as public even if the client thinks they're authenticated.
┌─────────────────────────────────────────────────────────────────────┐
│ SESSION EXPIRATION FLOW │
└─────────────────────────────────────────────────────────────────────┘
User reloads after 24+ hours
│
▼
localStorage: userType='friend', sessionId='abc123'
│
▼
POST /task/api/session/handshake
Headers: { X-User-Type: 'friend', X-Session-Id: 'abc123' }
│
▼
Edge-router: KV lookup for session → expired/not found
│
▼
Response: { userType: 'public', ... } ← Server disagrees with client
│
▼
Client detects mismatch: expected 'friend', got 'public'
│
▼
Show toast: "Your session has expired. Please enter your key again."
│
▼
Update localStorage: userType='public'
│
▼
Page reload → App reinitializes as public user
Key implementation details:
performSessionHandshake()returns{ preferences, serverUserType }useSessionInitializationcomparesserverUserTypewith client'suserType- On mismatch where authenticated user becomes public: show toast, update localStorage, reload
- This prevents "lost context" where user sees empty/public data instead of their authenticated data
This micro-frontend delegates most authentication to the parent application.
The task app does NOT:
- ❌ Handle user credentials or passwords
- ❌ Hash passwords
- ❌ Generate session tokens
- ❌ Store authentication keys
The task app DOES:
- ✅ Call parent's
/validate-keyand/session/createendpoints - ✅ Store
sessionIdanduserTypein localStorage after auth - ✅ Use these for storage namespacing (localStorage keys, API requests)
- ✅ Validate operations based on
userType(e.g., public can't sync to server)
Your parent application must handle:
-
User Authentication
- Login/logout flows
- Session management
- Token generation and validation
-
Key Validation
- Implement
/validate-keyendpoint - Verify keys against your auth system
- Return
{ valid: true/false }
- Implement
-
API Security
- Validate
X-User-Typeheader - Verify
X-Session-Idagainst your session store - Return 403 for unauthorized requests
- Validate
-
Props to Task App
- Provide correct
userType(public, friend, admin, etc.) - Provide stable
sessionId(changes on logout/login)
- Provide correct
- ✅ No credentials stored or handled
- ✅ No authentication logic exposed in codebase
- ✅ All sensitive operations delegated to parent
- ✅ Uses standard web APIs (localStorage, fetch) safely
- ✅ React escapes output by default (XSS protection)
- ✅ No direct database access (SQL injection-proof)
Separation of concerns makes this safe to open source while keeping authentication private.
Export pure handlers and a Storage interface. Parent implements storage for their environment.
// Task app exports (src/server/index.ts)
export * as TaskHandlers from './handlers.js'
export type { Storage as TaskStorage } from './storage.js'
// Parent implements storage
const storage: TaskStorage = {
getTasks: async userType => {
/* KV, filesystem, DB */
},
saveTasks: async (userType, tasks) => {
/* ... */
},
getStats: async userType => {
/* ... */
},
saveStats: async (userType, stats) => {
/* ... */
}
}
// Use with ANY framework
app.post('/task/api', async (req, res) => {
const result = await TaskHandlers.createTask(storage, auth, req.body)
res.json(result)
})- No Express/Hono/framework dependencies
- Works in any JavaScript runtime (Node.js, Workers, Deno)
- Easy to test (mock the Storage interface)
- Reusable across different parent apps
- Cloudflare Workers KV (production)
- Filesystem (development)
- Database (SQL/NoSQL)
- In-memory (testing)
- React 18 - UI library
- TypeScript - Type safety
- CSS Custom Properties - Theming
- localStorage - Client-side storage
- BroadcastChannel - Cross-tab sync
App (main orchestrator)
├── Theme State Management
├── User Management
└── Custom Hooks
├── useTasks() - CRUD operations, board management
├── useTaskHandlers() - UI integration layer (modals, validation)
├── useSessionInitialization() - Session setup and sync
├── useDragAndDrop() - Drag and drop logic
├── useTaskSort() - Sort state
├── usePreferences() - Settings sync
└── useTheme() - Theme switching
├── AppModals - Centralized modal management
│ ├── SettingsModal
│ ├── CreateBoardModal
│ ├── CreateTagModal
│ ├── EditTagModal
│ ├── ClearTagModal
│ ├── BoardContextMenu
│ └── TagContextMenu
├── TaskLayout (grid/column layout)
│ ├── BoardButton (board navigation)
│ ├── TagFilterButton (tag filtering)
│ └── TaskItem (individual task card)
│ ├── Complete/delete buttons
│ ├── Inline edit
│ └── Tag management
└── MarqueeOverlay - Drag selection overlay
- User Action → Component (TaskItem, TagFilterButton, etc.)
- Component → Hook (useTasks)
- Hook → API Client (optimistic localStorage update)
- API Client → Background server sync (if not public)
- localStorage change → BroadcastChannel event
- Other tabs → Receive event and refresh
All user types use localStorage for instant UI:
// 1. Update localStorage immediately (UI updates)
await localStorage.createTask(task)
// 2. If not public, sync to server in background
if (userType !== 'public') {
fetch('/task/api', { method: 'POST', body: JSON.stringify(task) }).catch(err =>
console.error('Background sync failed')
)
}Benefits:
- ✅ Zero UI blocking
- ✅ Instant feedback
- ✅ Works offline (public mode)
- ✅ Server sync happens in background
src/
├── api/ # API client layer
│ ├── client.ts # Optimistic API with extensive logging
│ ├── localStorageApi.ts # localStorage implementation
│ ├── session.ts # Session management
│ └── storage/ # Storage implementations
│
├── app/ # Application entry and config
│ ├── App.tsx # Main orchestrator (~400 lines, refactored)
│ ├── entry.tsx # React mount point
│ ├── themeConfig.tsx # Theme configuration
│ └── constants.ts # App constants
│
├── components/ # UI components
│ ├── AppModals.tsx # Centralized modal management
│ ├── MarqueeOverlay.tsx # Drag selection overlay
│ ├── TaskLayout.tsx # Main layout component
│ ├── TaskItem.tsx # Individual task card
│ ├── BoardsSection.tsx # Board navigation
│ ├── TagFiltersSection.tsx # Tag filters
│ └── modals/ # Modal components
│ ├── SettingsModal.tsx
│ ├── CreateBoardModal.tsx
│ ├── CreateTagModal.tsx
│ ├── EditTagModal.tsx
│ ├── ClearTagModal.tsx
│ ├── BoardContextMenu.tsx
│ └── TagContextMenu.tsx
│
├── domain/ # Business logic
│ ├── types.ts # TypeScript interfaces
│ ├── handlers/ # Pure handler functions
│ └── utils/ # Domain utilities (tags, validation)
│
├── hooks/ # Custom React hooks
│ ├── useTasks/ # Task operations (CRUD, boards)
│ │ ├── index.ts # Main hook (~400 lines)
│ │ └── helpers.ts # Shared utilities
│ ├── useTaskHandlers.ts # UI integration layer
│ ├── useSessionInitialization.ts # Session setup
│ ├── useDragAndDrop/ # Drag and drop logic
│ ├── useModalState.ts # Modal state management
│ ├── usePreferences.ts # Settings persistence
│ └── useTheme.ts # Theme switching
│
├── server/ # Exported handlers for backend
│ ├── index.ts # Exports
│ ├── handlers.ts # Business logic
│ └── storage.ts # Storage interface
│
├── styles/ # CSS modules
│ ├── base.css # Design tokens + base styles
│ ├── main.css # Layout and structure
│ ├── buttons.css # Button styles
│ ├── modal.css # Modal dialogs
│ ├── task-items.css # Task cards
│ └── task-layout.css # Grid layouts
│
└── utils/ # App utilities
├── auth.ts # Authentication helpers
├── broadcast.ts # Cross-tab sync
├── dragDrop.ts # Drag and drop utilities
├── formatters.ts # Text formatting
├── layout.ts # Layout calculations
├── placeholders.ts # Placeholder text
├── preferences.ts # Preferences helpers
└── validation.ts # Input validation
Pure functions with no side effects:
export async function createTask(
storage: Storage,
auth: AuthContext,
input: CreateTaskInput
): Promise<Task> {
// 1. Load current state
const tasks = await storage.getTasks(auth.userType)
const stats = await storage.getStats(auth.userType)
// 2. Pure transformation
const newTask = {
id: generateULID(),
title: input.title,
tag: input.tag,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
// 3. Save new state
await storage.saveTasks(auth.userType, {
...tasks,
tasks: [...tasks.tasks, newTask]
})
await storage.saveStats(auth.userType, {
...stats,
counters: { ...stats.counters, totalCreated: stats.counters.totalCreated + 1 }
})
return newTask
}Benefits:
- ✅ Testable (just mock Storage)
- ✅ Framework-agnostic
- ✅ No hidden dependencies
- ✅ Easy to reason about
interface Storage {
getTasks(userType: UserType): Promise<TasksFile>
saveTasks(userType: UserType, tasks: TasksFile): Promise<void>
getStats(userType: UserType): Promise<StatsFile>
saveStats(userType: UserType, stats: StatsFile): Promise<void>
}Implementations:
Filesystem (included for development):
const storage = createFilesystemStorage('./data')Cloudflare Workers KV (parent implements):
const storage = {
getTasks: async userType => {
const data = await env.TASK_KV.get(`tasks:${userType}`, 'json')
return data || defaultTasksFile
},
saveTasks: async (userType, tasks) => {
await env.TASK_KV.put(`tasks:${userType}`, JSON.stringify(tasks))
}
// ... getStats, saveStats
}Modular CSS files for separation of concerns:
src/styles/
├── base.css # Base styles, design tokens, and CSS reset
├── main.css # Layout, header, app structure
├── buttons.css # All button variants
├── modal.css # Modal dialogs
├── task-items.css # Task card styles
├── task-layout.css # Grid/column layouts
└── index.css # Import orchestrator
9 theme families (18 themes with light/dark) with ~40 CSS variables each:
:root {
/* Light theme (default) */
}
[data-theme='dark'] {
/* Dark theme */
}
[data-theme='strawberry-light'] {
/* Strawberry light — each family has light + dark */
}
/* ... 7 more families × 2 variants */Theme variables structure:
- Primary colors (6 variants incl.
--color-on-primary) - Success colors (5 variants incl.
--color-on-success) - Warning colors (4 variants incl.
--color-on-warning) - Danger colors (6 variants incl.
--color-on-danger) - Neutral colors (6 variants incl.
--color-on-neutral) - Text colors (4: primary, secondary, tertiary, muted)
- Border colors (2: default, light)
- Background colors (5: default, card, alt, hover, overlay)
- Shadows (6: sm, md, lg, focus, focus-sm, focus-alt)
Note:
--color-on-*variables define the text color to use ON TOP OF that color's surface. These are computed from the color's luminance, not the theme mode. See THEME_CREATION_GUIDE.md for details.
Theme switching:
// Instant switching via data attribute
document.documentElement.setAttribute('data-theme', 'dark')All values use CSS custom properties (namespaced for Tailwind v4 compatibility):
- ✅ Colors:
var(--color-primary) - ✅ Spacing:
var(--hdk-space-md) - ✅ Typography:
var(--hdk-text-base) - ✅ Borders:
var(--hdk-radius) - ✅ Shadows:
var(--hdk-shadow-md)
Benefits:
- Instant theme switching
- Consistent design system
- Easy to add new themes
- No JavaScript needed for theming
localStorage keys use sessionId for stability:
// Tasks: {userType}-{sessionId}-{boardId}-tasks
'public-abc123-main-tasks'
'friend-xyz789-work-tasks'
// Boards index: {userType}-{sessionId}-boards
'admin-def456-boards'
// Stats: {userType}-{sessionId}-{boardId}-stats
'friend-xyz789-main-stats'Why sessionId?
- ✅ Stable across page reloads
- ✅ Parent controls session lifecycle
- ✅ Multiple devices/tabs can coexist
- ✅ Clean separation per user
Per-board storage:
data/
{userType}/
{sessionId}/
boards.json # Board index
{boardId}-tasks.json # Tasks for board
{boardId}-stats.json # Stats for board
Benefits:
- Boards load independently
- Parallel board operations
- Efficient sync (only changed boards)
- Client: ~95KB raw, ~22KB gzipped
- CSS: ~40KB raw, ~7KB gzipped (includes all theme families)
- Initial load: ~50-100ms on average connection
- Task operations: Instant (localStorage)
- Theme switching: Instant (CSS custom properties)
- Drag and drop: 60fps (CSS transforms)
- Cross-tab sync: ~50ms (BroadcastChannel)
Handler execution:
- Pure functions: No overhead
- Storage-dependent: Varies by implementation
- In-memory: <1ms
- Filesystem: ~5-10ms
- Cloudflare KV: ~10-50ms (depends on region)
- Database: Varies by DB and query
Separation of business logic from framework routing
Abstract storage implementation from business logic
Business logic without side effects for testability
Extract stateful React logic into reusable hooks
Modular UI rendering with clear responsibilities
Centralized theme configuration with custom properties
Instant UI feedback with background server sync
New Task Property:
- Update
Taskinterface insrc/domain/types.ts - Update handler logic in
src/domain/handlers/handlers.ts - Update UI in
src/components/TaskItem.tsx
New Handler:
- Add function to
src/domain/handlers/handlers.ts - Export in
src/server/index.ts - Parent uses handler with their framework
New Storage Backend:
- Implement
Storageinterface - Use with existing handlers (no changes needed)
New Theme:
- Add theme definition in
themes/src/style.css(~40 variables: 34 color + 6 shadow) - Add to
THEMESarray inthemes/src/index.ts - Add theme family in
themes/src/metadata.tsxwith icon
New Component:
- Create in
src/components/ - Add styles to appropriate CSS file
- Import in parent component
Unit Testing (handlers):
import { TaskHandlers } from '@wolffm/task/api'
test('createTask adds new task', async () => {
const storage = createMockStorage()
const auth = { userType: 'public' }
const input = { title: 'Test', tag: 'work' }
const result = await TaskHandlers.createTask(storage, auth, input)
expect(result.title).toBe('Test')
expect(result.tag).toBe('work')
})Integration Testing (with real storage):
test('full CRUD workflow', async () => {
const storage = createFilesystemStorage('/tmp/test')
const task = await TaskHandlers.createTask(storage, auth, { title: 'Test' })
const updated = await TaskHandlers.updateTask(storage, auth, task.id, { title: 'Updated' })
await TaskHandlers.completeTask(storage, auth, task.id)
const tasks = await TaskHandlers.getTasks(storage, auth)
expect(tasks.tasks).toHaveLength(0) // Task moved to graveyard
})dist/
├── index.js # React app (~95KB / ~22KB gzipped)
└── style.css # All themes (~40KB / ~7KB gzipped)
dist/server/
├── index.js # Exports (TaskHandlers, TaskStorage)
├── handlers.js # Pure business logic
├── storage.js # Storage interface
├── types.js # TypeScript types
└── utils.js # Utilities
The app includes comprehensive logging via @wolffm/task-ui-components/logger:
import { logger } from '@wolffm/task-ui-components'
// Info logs (dev/admin only)
logger.info('[api] createTask: Starting', { title, boardId })
logger.info('[api] createTask: Created locally', { taskId })
// Warnings (always shown)
logger.warn('[api] createTask: Server sync returned error', { status })
// Errors (always shown)
logger.error('[api] createTask: Server sync failed', { error })Behavior:
| Mode | Development | Production (Admin) | Production (User) |
|---|---|---|---|
info() |
✅ | ✅ | ❌ |
debug() |
✅ | ✅ | ❌ |
warn() |
✅ | ✅ | ✅ |
error() |
✅ | ✅ | ✅ |
Admin Mode:
// Enable admin logging in production
if (userType === 'admin') {
logger.setAdminStatus(true)
}Logging Coverage:
- ✅ All API operations (start, local complete, server sync)
- ✅ Performance metrics (sync duration)
- ✅ Error details with context
- ✅ State changes in hooks
- ✅ Cross-tab sync events
Before: 633 lines of mixed concerns
After: 404 lines focused on composition (36% reduction)
Extracted Components:
- AppModals (201 lines) - All modal components centralized
- MarqueeOverlay (27 lines) - Drag selection overlay
Extracted Hooks:
- useTaskHandlers (207 lines) - UI integration layer
- Modal state management
- Input validation
- Tag/board creation with pending operations
- useSessionInitialization (117 lines) - Session setup
- Initial API sync
- Preference loading
- Authentication state
Extracted Utilities:
- domain/utils/tags.ts - Tag parsing and formatting
splitTags()- Consistent tag parsingformatError()- Error message formatting
useTasks - Pure business logic
- CRUD operations
- Board management
- API calls
- BroadcastChannel sync
useTaskHandlers - UI integration
- Connects useTasks to UI components
- Modal state coordination
- Input validation
- Pending operation management
Separation Benefits:
- ✅ Clear separation of concerns
- ✅ Easier testing (mock at different levels)
- ✅ Better code organization
- ✅ Reduced component complexity
Tasks optionally include startTime and endTime fields (ISO 8601 format, matching createdAt/updatedAt/closedAt).
Scheduling modes:
- Neither field - Classic board task (existing behavior)
- startTime only - Open-ended event, starts at specific time
- endTime only - Deadline/due date
- Both fields - Fully scheduled time block
Tasks without scheduling fields appear only in board view. Tasks with scheduling appear in both board view and calendar view.
The app uses a view-agnostic data layer - tasks are stored the same way regardless of how they're displayed.
Planned views:
- Board View - Existing tag-based column layout (current)
- Day View - Calendar-style day visualization (planned)
- Week View - Weekly calendar grid (future)
- Month View - Monthly overview (future)
Key principle: All views operate on the same board's tasks. A task created in calendar view appears in board view and vice versa.
A view mode selector will be added to the header area, allowing users to toggle between Board and Calendar views while staying on the same board.
- README.md - Getting started and integration
- API.md - Complete endpoint reference
- CONTRIBUTING.md - Development workflow
- CHANGELOG.md - Version history