Initial commit -- just started to factor and implement python fast API backend

This commit is contained in:
2026-05-11 12:48:35 +10:00
commit b1b621bc4a
23 changed files with 4101 additions and 0 deletions
@@ -0,0 +1,6 @@
{
"version": 1,
"created": "2026-05-08T02:04:31.986Z",
"modified": "2026-05-08T02:04:42.961Z",
"objects": []
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dashy — task dashboard</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
<script src="db.js"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="screens.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
// Dashy — main app (DB-backed via SQLite/WASM)
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"accent": "#2A6FDB",
"density": "cozy",
"showTags": true
}/*EDITMODE-END*/;
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
function useDashyDB() {
const [ready, setReady] = React.useState(DashyDB.isReady);
const [, force] = React.useReducer(x => x + 1, 0);
React.useEffect(() => {
if (!DashyDB.isReady) DashyDB.init().then(() => setReady(true));
const off = DashyDB.subscribe(() => force());
return off;
}, []);
return ready;
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const ready = useDashyDB();
const [authed, setAuthed] = React.useState(false);
const [meId, setMeId] = React.useState('rod');
const [tab, setTab] = React.useState('overview');
const [adding, setAdding] = React.useState(null);
const [openTaskId, setOpenTaskId] = React.useState(null);
const [showLogs, setShowLogs] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
const [showDB, setShowDB] = React.useState(false);
const [headsUp, setHeadsUp] = React.useState([
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
title: 'WO #2188 auto-marked Unsuccessful',
sub: 'Two missed bookings — assigned to Kirra for review' },
{ id: 'h2', kind: 'billing', taskId: 't4',
title: 'Form response switched to Billing',
sub: 'K. Wynne · originally Service Booking' },
]);
if (!ready) return <BootSplash />;
const tasks = DashyDB.listTasks();
const audit = DashyDB.listAudit();
const dbUsers = DashyDB.listUsers();
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
const merge = (id) => {
const base = findUser(id); if (!base && !userMap[id]) return null;
const live = userMap[id] || {};
if (!base) {
// user added at runtime
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
photo: live.photo || null, account_type: live.account_type || 'standard' };
}
return { ...base, name: live.name || base.name, role: live.role || base.role,
photo: live.photo || null, account_type: live.account_type || 'standard' };
};
const me = merge(meId);
const isAdmin = me && me.account_type === 'admin';
const openTask = tasks.find(x => x.id === openTaskId);
const handleLogin = (id) => {
setMeId(id); setAuthed(true);
DashyDB.addAudit({
actor: id, action: 'login',
summary: (merge(id) || {}).name + ' signed in',
});
};
const addTask = ({ title, description, assignee, priority }) => {
const id = 't_' + Date.now().toString(36);
const at = new Date('2026-05-08T10:30:00').toISOString();
DashyDB.createTask({
id, title, description, assignee, priority,
addedBy: meId, source: 'manual', status: 'open', addedAt: at, tags: []
});
DashyDB.addAudit({
at, actor: meId, action: 'task_created',
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
target: id
});
setAdding(null);
};
const moveTask = (taskId, toUserId) => DashyDB.moveTask(taskId, toUserId, meId);
const setPriority = (taskId, p) => DashyDB.setPriority(taskId, p);
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
if (!authed) return <LoginScreen onLogin={handleLogin} />;
return (
<div className="app">
<TopBar
me={me}
isAdmin={isAdmin}
tab={tab}
setTab={setTab}
onAdd={() => setAdding(meId)}
onLogs={() => setShowLogs(true)}
onProfile={() => setShowSettings(true)}
onDB={() => setShowDB(true)}
/>
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
<main className="main">
{tab === 'overview' && (
<OverviewScreen
tasks={tasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)}
onMoveTask={moveTask}
/>
)}
{tab !== 'overview' && (
<UserScreen
user={merge(tab)} tasks={tasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)}
/>
)}
</main>
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} />
{openTask && (
<TaskDetail task={openTask} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} />
)}
{showLogs && (
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
<AuditScreen entries={audit} onOpen={openTaskFromAnywhere} />
</Modal>
)}
{showSettings && (
<SettingsScreen
user={me}
isAdmin={isAdmin}
onClose={() => setShowSettings(false)}
onSave={(edits) => {
DashyDB.updateUser(meId, edits);
DashyDB.addAudit({ actor: meId, action: 'profile_updated', summary: 'Updated profile details' });
}}
onLogout={() => { setShowSettings(false); setAuthed(false); }}
onSwitchUser={(id) => { setMeId(id); setShowSettings(false); }}
onCreateUser={(u) => {
const id = DashyDB.createUser(u);
DashyDB.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
}}
onDeleteUser={(id) => {
const u = userMap[id];
DashyDB.deleteUser(id);
DashyDB.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null });
}}
onUpdateUserRole={(id, edits) => {
DashyDB.updateUser(id, edits);
DashyDB.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null });
}}
/>
)}
{showDB && isAdmin && <DatabaseInspector onClose={() => setShowDB(false)} />}
<DashyTweaks t={t} setTweak={setTweak} />
</div>
);
}
function BootSplash() {
return (
<div className="boot">
<div className="boot__pulse" />
<div className="boot__label mono">opening dashy.db</div>
</div>
);
}
function DashyTweaks({ t, setTweak }) {
return (
<TweaksPanel title="Tweaks">
<TweakSection title="Appearance">
<TweakRadio label="Theme" value={t.theme}
options={[{value:'light',label:'Light'},{value:'dark',label:'Dark'}]}
onChange={v => setTweak('theme', v)} />
<TweakColor label="Accent" value={t.accent} options={ACCENTS} onChange={v => setTweak('accent', v)} />
<TweakRadio label="Density" value={t.density}
options={[{value:'compact',label:'Compact'},{value:'cozy',label:'Cozy'}]}
onChange={v => setTweak('density', v)} />
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
</TweakSection>
<TweakSection title="Database">
<TweakButton label="Reset SQLite" onClick={() => { if (confirm('Wipe and reseed dashy.db?')) DashyDB.reset(); }}>
Reset
</TweakButton>
</TweakSection>
</TweaksPanel>
);
}
function ThemeBridge() {
const [t] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => {
document.documentElement.dataset.theme = t.theme;
document.documentElement.style.setProperty('--accent', t.accent);
document.documentElement.dataset.showTags = t.showTags ? 'on' : 'off';
}, [t.theme, t.accent, t.showTags]);
return null;
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<><ThemeBridge /><App /></>);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
# SECRET_KEY should be in an environment variable in production
SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
# Use an environment variable for the DB path, or default to local dashy.db
# On CIFS mounts, SQLite locking will fail, so we recommend a local path.
DB_PATH = os.getenv("DASHY_DB_PATH", "./dashy.db")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False, "timeout": 30}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+101
View File
@@ -0,0 +1,101 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List
import uuid
from . import models, schemas, auth, database
from .database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="Dashy API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify your frontend URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/token", response_model=schemas.Token)
async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == form_data.id).first()
if not user or not auth.verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth.create_access_token(data={"sub": user.id})
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users", response_model=List[schemas.User])
def read_users(db: Session = Depends(get_db)):
return db.query(models.User).all()
@app.get("/tasks", response_model=List[schemas.Task])
def read_tasks(db: Session = Depends(get_db)):
return db.query(models.Task).all()
@app.post("/tasks", response_model=schemas.Task)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
task_id = task.id or f"t_{uuid.uuid4().hex[:8]}"
db_task = models.Task(
id=task_id,
title=task.title,
description=task.description,
assignee_id=task.assignee_id,
added_by=task.added_by,
priority=task.priority,
source=task.source,
status=task.status,
due_at=task.due_at,
reminder_at=task.reminder_at
)
db.add(db_task)
for tag_name in task.tags:
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
if not tag:
tag = models.Tag(tag=tag_name)
db.add(tag)
db_task.tags.append(tag)
db.commit()
db.refresh(db_task)
return db_task
@app.patch("/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db)):
db_task = db.query(models.Task).filter(models.Task.id == task_id).first()
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_task, key, value)
db.commit()
db.refresh(db_task)
return db_task
@app.get("/audit", response_model=List[schemas.AuditLog])
def read_audit(db: Session = Depends(get_db)):
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
@app.post("/audit", response_model=schemas.AuditLog)
def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db)):
audit_id = f"a_{uuid.uuid4().hex[:8]}"
db_audit = models.AuditLog(
id=audit_id,
actor=audit.actor,
action=audit.action,
summary=audit.summary,
target=audit.target
)
db.add(db_audit)
db.commit()
db.refresh(db_audit)
return db_audit
+84
View File
@@ -0,0 +1,84 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .database import Base
task_tags = Table(
"task_tags",
Base.metadata,
Column("task_id", String, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
Column("tag", String, ForeignKey("tags.tag", ondelete="CASCADE"), primary_key=True),
)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
role = Column(String, nullable=False)
hue = Column(Integer, nullable=False)
initials = Column(String, nullable=False)
email = Column(String)
phone = Column(String)
photo = Column(String)
password_hash = Column(String)
account_type = Column(String, nullable=False, default="standard")
created_at = Column(DateTime(timezone=True), server_default=func.now())
tasks = relationship("Task", back_populates="assignee", foreign_keys="Task.assignee_id")
notes = relationship("TaskNote", back_populates="author")
class Task(Base):
__tablename__ = "tasks"
id = Column(String, primary_key=True)
title = Column(String, nullable=False)
description = Column(String)
assignee_id = Column(String, ForeignKey("users.id"), nullable=False)
added_by = Column(String, nullable=False)
priority = Column(String, nullable=False) # low, med, high
source = Column(String, nullable=False) # manual, imessage, email, automation
status = Column(String, nullable=False, default="open")
added_at = Column(DateTime(timezone=True), server_default=func.now())
due_at = Column(DateTime(timezone=True))
reminder_at = Column(DateTime(timezone=True))
assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id])
tags = relationship("Tag", secondary=task_tags, back_populates="tasks")
notes = relationship("TaskNote", back_populates="task", cascade="all, delete")
class Tag(Base):
__tablename__ = "tags"
tag = Column(String, primary_key=True)
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
class TaskNote(Base):
__tablename__ = "task_notes"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
author_id = Column(String, ForeignKey("users.id"), nullable=False)
body = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
task = relationship("Task", back_populates="notes")
author = relationship("User", back_populates="notes")
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(String, primary_key=True)
at = Column(DateTime(timezone=True), server_default=func.now())
actor = Column(String, nullable=False)
action = Column(String, nullable=False)
summary = Column(String, nullable=False)
target = Column(String)
class Session(Base):
__tablename__ = "sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
device = Column(String, nullable=False)
location = Column(String)
last_active = Column(DateTime(timezone=True), server_default=func.now())
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
pydantic-settings
python-jose[cryptography]
passlib
bcrypt==4.0.1
python-multipart
+106
View File
@@ -0,0 +1,106 @@
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
class UserBase(BaseModel):
id: str
name: str
role: str
hue: int
initials: str
email: Optional[str] = None
phone: Optional[str] = None
photo: Optional[str] = None
account_type: str = "standard"
class UserCreate(UserBase):
password: str
class User(UserBase):
created_at: datetime
class Config:
from_attributes = True
class TaskNoteBase(BaseModel):
body: str
class TaskNoteCreate(TaskNoteBase):
task_id: str
author_id: str
class TaskNote(TaskNoteBase):
id: int
task_id: str
author_id: str
created_at: datetime
class Config:
from_attributes = True
class TagBase(BaseModel):
tag: str
class Tag(TagBase):
class Config:
from_attributes = True
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
assignee_id: str
added_by: str
priority: str
source: str
status: str = "open"
due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None
class TaskCreate(TaskBase):
id: Optional[str] = None
tags: List[str] = []
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[str] = None
priority: Optional[str] = None
status: Optional[str] = None
due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None
class Task(TaskBase):
id: str
added_at: datetime
tags: List[Tag] = []
notes: List[TaskNote] = []
class Config:
from_attributes = True
class AuditLogBase(BaseModel):
actor: str
action: str
summary: str
target: Optional[str] = None
class AuditLog(AuditLogBase):
id: str
at: datetime
class Config:
from_attributes = True
class SessionBase(BaseModel):
user_id: str
device: str
location: Optional[str] = None
class Session(SessionBase):
id: int
last_active: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
+150
View File
@@ -0,0 +1,150 @@
from sqlalchemy.orm import Session
from .database import SessionLocal, engine
from . import models, auth
from datetime import datetime
# Import data directly as if it were coming from data.jsx
USERS = [
{"id": "rod", "name": "Rod", "role": "Owner", "hue": 220, "initials": "R"},
{"id": "lani", "name": "Lani", "role": "Admin", "hue": 340, "initials": "L"},
{"id": "kirra", "name": "Kirra", "role": "Technician", "hue": 160, "initials": "K"},
{"id": "ayron", "name": "Ayron", "role": "Technician", "hue": 40, "initials": "A"},
]
SEED_TASKS = [
{ "id": 't1', "title": 'Call back Mrs. Patel re: Hilux service quote',
"description": 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
"assignee": 'lani', "addedBy": 'rod', "priority": 'high', "source": 'imessage',
"addedAt": '2026-05-08T08:42:00', "status": 'open',
"tags": ['quote'] },
{ "id": 't2', "title": 'Email #3814 → Workorder #2207',
"description": 'Auto-converted from inbox. Tell customer ETA of Friday.',
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'email',
"addedAt": '2026-05-08T07:15:00', "status": 'open',
"tags": ['WO #2207'] },
{ "id": 't3', "title": 'Reorder coolant — 5L × 4',
"description": 'Stock card ran red on the morning sweep.',
"assignee": 'lani', "addedBy": 'kirra', "priority": 'low', "source": 'manual',
"addedAt": '2026-05-07T16:02:00', "status": 'open', "tags": [] },
{ "id": 't4', "title": 'Form response from "K. Wynne" auto-switched to Billing',
"description": 'Originally captured as Service Booking — heads up.',
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'automation',
"addedAt": '2026-05-08T09:50:00', "status": 'billing',
"tags": ['form'] },
{ "id": 't5', "title": 'Diagnose intermittent misfire — Camry, WO #2199',
"description": 'Cust says it stutters between 6080km/h once warm.',
"assignee": 'kirra', "addedBy": 'rod', "priority": 'high', "source": 'manual',
"addedAt": '2026-05-08T07:50:00', "status": 'open',
"tags": ['WO #2199'] },
{ "id": 't6', "title": 'Sign off on Ayron\'s brake job — Forester',
"description": 'Final torque check + road test before pickup at 3pm.',
"assignee": 'kirra', "addedBy": 'ayron', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T09:05:00', "status": 'open', "tags": [] },
{ "id": 't7', "title": 'WO #2188 auto-marked Unsuccessful',
"description": 'Customer no-show twice — please review and decide on next step.',
"assignee": 'kirra', "addedBy": 'system', "priority": 'high', "source": 'automation',
"addedAt": '2026-05-08T06:00:00', "status": 'unsuccessful',
"tags": ['WO #2188'] },
{ "id": 't8', "title": 'Replace front pads + rotors — Forester',
"description": 'Parts arrived yesterday. Bay 2 from 10am.',
"assignee": 'ayron', "addedBy": 'kirra', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T08:00:00', "status": 'open',
"tags": ['WO #2201'] },
{ "id": 't9', "title": 'Tidy bay 3 + sweep before lunch',
"description": '',
"assignee": 'ayron', "addedBy": 'rod', "priority": 'low', "source": 'imessage',
"addedAt": '2026-05-08T09:30:00', "status": 'open', "tags": [] },
{ "id": 't10', "title": 'Pickup parts from Repco @ 11:30',
"description": 'Two boxes for WO #2199 + an oil filter for stock.',
"assignee": 'ayron', "addedBy": 'lani', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T08:20:00', "status": 'open', "tags": [] },
{ "id": 't11', "title": 'Approve quote on Job #2207 ($1,840)',
"description": 'Lani flagged this — needs your sign-off before sending.',
"assignee": 'rod', "addedBy": 'lani', "priority": 'high', "source": 'manual',
"addedAt": '2026-05-08T09:12:00', "status": 'open',
"tags": ['quote', 'WO #2207'] },
{ "id": 't12', "title": 'Review weekly automation report',
"description": '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
"assignee": 'rod', "addedBy": 'system', "priority": 'low', "source": 'automation',
"addedAt": '2026-05-08T06:00:00', "status": 'open', "tags": [] },
]
SEED_AUDIT = [
{ "id": 'a1', "at": '2026-05-08T09:50:00', "actor": 'system', "action": 'form_rerouted',
"summary": 'Form from K. Wynne auto-switched: Service Booking → Billing form',
"target": 't4' },
{ "id": 'a2', "at": '2026-05-08T09:30:00', "actor": 'rod', "action": 'task_created',
"summary": 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
"target": 't9' },
{ "id": 'a3', "at": '2026-05-08T09:12:00', "actor": 'lani', "action": 'task_assigned',
"summary": 'Assigned "Approve quote on Job #2207" to ROD',
"target": 't11' },
{ "id": 'a4', "at": '2026-05-08T09:05:00', "actor": 'ayron', "action": 'task_moved',
"summary": 'Moved "Sign off on brake job" from Ayron → Kirra',
"target": 't6' },
]
def seed_db():
# Create tables
models.Base.metadata.create_all(bind=engine)
db = SessionLocal()
# Add Users
for u in USERS:
is_admin = u['id'] in ['rod', 'ayron']
db_user = models.User(
id=u['id'],
name=u['name'],
role=u['role'],
hue=u['hue'],
initials=u['initials'],
email=f"{u['id']}@murchison-auto.co",
phone="+64 27 555 0184",
password_hash=auth.get_password_hash("password123"), # Default password
account_type='admin' if is_admin else 'standard'
)
db.merge(db_user)
# Add Tasks
for t in SEED_TASKS:
db_task = models.Task(
id=t['id'],
title=t['title'],
description=t['description'],
assignee_id=t['assignee'],
added_by=t['addedBy'],
priority=t['priority'],
source=t['source'],
status=t['status'],
added_at=datetime.fromisoformat(t['addedAt'])
)
db.merge(db_task)
# Add tags
for tag_name in t.get('tags', []):
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
if not tag:
tag = models.Tag(tag=tag_name)
db.add(tag)
if tag not in db_task.tags:
db_task.tags.append(tag)
# Add Audit
for a in SEED_AUDIT:
db_audit = models.AuditLog(
id=a['id'],
at=datetime.fromisoformat(a['at']),
actor=a['actor'],
action=a['action'],
summary=a['summary'],
target=a['target']
)
db.merge(db_audit)
db.commit()
db.close()
if __name__ == "__main__":
seed_db()
print("Database seeded!")
+174
View File
@@ -0,0 +1,174 @@
// Shared primitives for Dashy
const USERS = [
{ id: 'lani', name: 'Lani', role: 'Front of house', hue: 28, initials: 'LA' },
{ id: 'kirra', name: 'Kirra', role: 'Workshop lead', hue: 145, initials: 'KI' },
{ id: 'ayron', name: 'Ayron', role: 'Tech', hue: 215, initials: 'AY' },
{ id: 'rod', name: 'ROD', role: 'Owner', hue: 280, initials: 'RO' },
];
const PRIORITY = {
high: { label: 'High', color: 'var(--prio-high)', dot: 'var(--prio-high)' },
med: { label: 'Med', color: 'var(--prio-med)', dot: 'var(--prio-med)' },
low: { label: 'Low', color: 'var(--prio-low)', dot: 'var(--prio-low)' },
};
const SOURCES = {
manual: { label: 'Manual', glyph: '·' },
imessage: { label: 'iMessage', glyph: '✦' },
email: { label: 'Email', glyph: '✉' },
automation:{ label: 'Auto', glyph: '⚙' },
};
function Avatar({ user, size = 28, ring = false }) {
if (!user) return null;
const s = { width: size, height: size, fontSize: Math.round(size * 0.42) };
if (user.photo) {
return (
<span
className={"avatar avatar--photo" + (ring ? " avatar--ring" : "")}
style={{
...s,
backgroundImage: `url(${user.photo})`,
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
}}
title={user.name}
/>
);
}
return (
<span
className={"avatar" + (ring ? " avatar--ring" : "")}
style={{
...s,
background: `oklch(94% 0.04 ${user.hue})`,
color: `oklch(32% 0.10 ${user.hue})`,
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
}}
title={user.name}
>
{user.initials}
</span>
);
}
function PriorityDot({ priority, withLabel = false }) {
const p = PRIORITY[priority];
if (!p) return null;
return (
<span className="prio">
<span className="prio__dot" style={{ background: p.dot }} />
{withLabel && <span className="prio__label">{p.label}</span>}
</span>
);
}
function SourceTag({ source }) {
const s = SOURCES[source];
if (!s || source === 'manual') return null;
return (
<span className="source-tag" data-source={source}>
<span className="source-tag__glyph">{s.glyph}</span>
{s.label}
</span>
);
}
function relTime(iso) {
const now = new Date('2026-05-08T10:30:00');
const then = new Date(iso);
const diff = (now - then) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
if (diff < 86400*7) return Math.floor(diff/86400) + 'd ago';
return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function fmtDateTime(iso) {
const d = new Date(iso);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
' · ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function findUser(id) { return USERS.find(u => u.id === id); }
function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) {
const author = findUser(task.addedBy);
const isAuto = task.status === 'unsuccessful' || task.status === 'billing';
return (
<article
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
data-density={density}
data-priority={task.priority}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/dashy-task', task.id);
onDragStart && onDragStart(task);
}}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={() => onOpen && onOpen(task)}
data-comment-anchor={"task-" + task.id}
>
<span className="card__grip" aria-hidden="true">
<span /><span /><span /><span /><span /><span />
</span>
<header className="card__head">
<h3 className="card__title">{task.title}</h3>
<PriorityDot priority={task.priority} />
</header>
{task.description && (
<p className="card__desc">{task.description}</p>
)}
{task.tags && task.tags.length > 0 && (
<div className="card__tags">
{task.tags.map(t => <span key={t} className="chip">{t}</span>)}
{task.source && task.source !== 'manual' && <SourceTag source={task.source} />}
</div>
)}
{isAuto && (
<div className="card__alert">
{task.status === 'unsuccessful'
? 'Auto-marked unsuccessful — needs review'
: 'Switched to billing form'}
</div>
)}
<footer className="card__foot">
<span className="card__author">
<Avatar user={author} size={20} />
<span>{author && author.name}</span>
</span>
<span className="card__time">{relTime(task.addedAt)}</span>
</footer>
</article>
);
}
function IconBtn({ children, onClick, label, variant = 'ghost' }) {
return (
<button className={"icon-btn icon-btn--" + variant} onClick={onClick} aria-label={label} title={label}>
{children}
</button>
);
}
// Tiny inline icons
const Icon = {
Plus: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 3v10M3 8h10" strokeLinecap="round"/></svg>,
Bell: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M4 7a4 4 0 1 1 8 0v3l1 2H3l1-2V7Z" strokeLinejoin="round"/><path d="M6.5 13a1.5 1.5 0 0 0 3 0"/></svg>,
Search: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3" strokeLinecap="round"/></svg>,
Logs: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 4h10M3 8h10M3 12h6" strokeLinecap="round"/></svg>,
Close: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round"/></svg>,
Check: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
Arrow: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 8h10m-4-4 4 4-4 4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
Dot: () => <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.5" fill="currentColor"/></svg>,
Pin: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M10 2 6 6H3l4 4-3 4 4-3 4 4v-3l4-4-2-2-2-4Z" strokeLinejoin="round"/></svg>,
iMessage: () => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2.5 7.5c0-2.8 2.5-5 5.5-5s5.5 2.2 5.5 5-2.5 5-5.5 5c-.6 0-1.2-.1-1.7-.2L3 13.5l.7-2.5c-.7-.9-1.2-1.9-1.2-3Z" strokeLinejoin="round"/></svg>,
};
Object.assign(window, {
USERS, PRIORITY, SOURCES,
Avatar, PriorityDot, SourceTag, TaskCard, IconBtn, Icon,
relTime, fmtDateTime, findUser,
});
View File
+47
View File
@@ -0,0 +1,47 @@
##### Conditions
- Multi User With auth per user
- Audit Logs for all actions (moving tasks, editing tasks, adding tasks and user auth)
- Create and mv tasks between users
- Notify User when new "task" is assigned to them
- iMessage --> Task
- Include date when tasks was added and who by
- Possibly a priority stamp? This would be on how important the task was needing to be completed....
- Heads up display of when a task was automatically changed to "Unsuccessful"
- Heads up display of when automatic form response is changed to "Billing form"
- Easy way to add notes and reminders.
##### What do I want this thing to do
- I want to create task for Users to-do (like a reminder / to-do / task list)
- Pop up notifications for when a reminder comes through to you??? >>>> Idk if this is going to be a native windows application for something???
- I think it would be handy to not only have this as a task "dashboard" but also as a way to keep track of what we have automated.
- it would be nice if when a task is taken from emails and added to whichever queue we decide to add it too -- it leaves a task in the dashy to tell staff (Lani in particular) "Hey email such and such has just been converted to workorder # (and have whatever the workorder number is)
-
##### iMessage --> Task ? What I mean
So what do I mean when I say "iMessage too task"
- What I mean by this is so that ROD can simply pick up his phone and ask siri or meta to send a message to "X" -- "X" being whatever he/we decide to name the contact for the openclaw agent.
- This message could be something allong the lines of "Create new note for Job 'Y' say 'blah blah blah'" This would then send that message to a Molty which would then create a new task (Specifically in l Lani's tasks list) to-do whatever ROD had sent to the agent.
##### Design
Dashboard Home screen will have 5 "tabs" at the top of the screen
- Overview (This will have all users coloums and tasks)
- Lani
- Kirra
- Ayron
- ROD
In overview tab there will be names (Lani, kirra, Aryon, ROD) beside each name will be a circular "+" icon to add tasks to each user. just below it. Each name will have a coloum with cards
These cards will be tasks for each user.
The cards will include
- the task that needs to be done (So a description)
- Which user added it (As each user wil have to log into their account)
- The time and date it was added.
Thats pretty much the sum of these cards -- its a to-do list basically or a tasks list
+123
View File
@@ -0,0 +1,123 @@
// Seed data for Dashy prototype
const SEED_TASKS = [
// Lani
{ id: 't1', title: 'Call back Mrs. Patel re: Hilux service quote',
description: 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
assignee: 'lani', addedBy: 'rod', priority: 'high', source: 'imessage',
addedAt: '2026-05-08T08:42:00', status: 'open',
tags: ['quote'] },
{ id: 't2', title: 'Email #3814 → Workorder #2207',
description: 'Auto-converted from inbox. Tell customer ETA of Friday.',
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'email',
addedAt: '2026-05-08T07:15:00', status: 'open',
tags: ['WO #2207'] },
{ id: 't3', title: 'Reorder coolant — 5L × 4',
description: 'Stock card ran red on the morning sweep.',
assignee: 'lani', addedBy: 'kirra', priority: 'low', source: 'manual',
addedAt: '2026-05-07T16:02:00', status: 'open' },
{ id: 't4', title: 'Form response from "K. Wynne" auto-switched to Billing',
description: 'Originally captured as Service Booking — heads up.',
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'automation',
addedAt: '2026-05-08T09:50:00', status: 'billing',
tags: ['form'] },
// Kirra
{ id: 't5', title: 'Diagnose intermittent misfire — Camry, WO #2199',
description: 'Cust says it stutters between 6080km/h once warm.',
assignee: 'kirra', addedBy: 'rod', priority: 'high', source: 'manual',
addedAt: '2026-05-08T07:50:00', status: 'open',
tags: ['WO #2199'] },
{ id: 't6', title: 'Sign off on Ayron\'s brake job — Forester',
description: 'Final torque check + road test before pickup at 3pm.',
assignee: 'kirra', addedBy: 'ayron', priority: 'med', source: 'manual',
addedAt: '2026-05-08T09:05:00', status: 'open' },
{ id: 't7', title: 'WO #2188 auto-marked Unsuccessful',
description: 'Customer no-show twice — please review and decide on next step.',
assignee: 'kirra', addedBy: 'system', priority: 'high', source: 'automation',
addedAt: '2026-05-08T06:00:00', status: 'unsuccessful',
tags: ['WO #2188'] },
// Ayron
{ id: 't8', title: 'Replace front pads + rotors — Forester',
description: 'Parts arrived yesterday. Bay 2 from 10am.',
assignee: 'ayron', addedBy: 'kirra', priority: 'med', source: 'manual',
addedAt: '2026-05-08T08:00:00', status: 'open',
tags: ['WO #2201'] },
{ id: 't9', title: 'Tidy bay 3 + sweep before lunch',
description: '',
assignee: 'ayron', addedBy: 'rod', priority: 'low', source: 'imessage',
addedAt: '2026-05-08T09:30:00', status: 'open' },
{ id: 't10', title: 'Pickup parts from Repco @ 11:30',
description: 'Two boxes for WO #2199 + an oil filter for stock.',
assignee: 'ayron', addedBy: 'lani', priority: 'med', source: 'manual',
addedAt: '2026-05-08T08:20:00', status: 'open' },
// ROD
{ id: 't11', title: 'Approve quote on Job #2207 ($1,840)',
description: 'Lani flagged this — needs your sign-off before sending.',
assignee: 'rod', addedBy: 'lani', priority: 'high', source: 'manual',
addedAt: '2026-05-08T09:12:00', status: 'open',
tags: ['quote', 'WO #2207'] },
{ id: 't12', title: 'Review weekly automation report',
description: '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
assignee: 'rod', addedBy: 'system', priority: 'low', source: 'automation',
addedAt: '2026-05-08T06:00:00', status: 'open' },
];
const SEED_AUDIT = [
{ id: 'a1', at: '2026-05-08T09:50:00', actor: 'system', action: 'form_rerouted',
summary: 'Form from K. Wynne auto-switched: Service Booking → Billing form',
target: 't4' },
{ id: 'a2', at: '2026-05-08T09:30:00', actor: 'rod', action: 'task_created',
summary: 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
target: 't9' },
{ id: 'a3', at: '2026-05-08T09:12:00', actor: 'lani', action: 'task_assigned',
summary: 'Assigned "Approve quote on Job #2207" to ROD',
target: 't11' },
{ id: 'a4', at: '2026-05-08T09:05:00', actor: 'ayron', action: 'task_moved',
summary: 'Moved "Sign off on brake job" from Ayron → Kirra',
target: 't6' },
{ id: 'a5', at: '2026-05-08T08:42:00', actor: 'rod', action: 'task_created',
summary: 'Created task "Call back Mrs. Patel" for Lani via iMessage',
target: 't1' },
{ id: 'a6', at: '2026-05-08T08:20:00', actor: 'lani', action: 'task_created',
summary: 'Created task "Pickup parts from Repco" for Ayron',
target: 't10' },
{ id: 'a7', at: '2026-05-08T08:00:00', actor: 'kirra', action: 'task_created',
summary: 'Created task "Replace front pads + rotors" for Ayron',
target: 't8' },
{ id: 'a8', at: '2026-05-08T07:50:00', actor: 'rod', action: 'task_edited',
summary: 'Edited priority on "Diagnose intermittent misfire" — Med → High',
target: 't5' },
{ id: 'a9', at: '2026-05-08T07:15:00', actor: 'system', action: 'email_converted',
summary: 'Email #3814 converted to Workorder #2207 (assigned to Lani)',
target: 't2' },
{ id: 'a10', at: '2026-05-08T06:00:00', actor: 'system', action: 'task_unsuccessful',
summary: 'WO #2188 auto-marked Unsuccessful after 2 missed bookings',
target: 't7' },
{ id: 'a11', at: '2026-05-07T16:02:00', actor: 'kirra', action: 'task_created',
summary: 'Created task "Reorder coolant" for Lani',
target: 't3' },
{ id: 'a12', at: '2026-05-07T15:30:00', actor: 'rod', action: 'login',
summary: 'Signed in from MacBook · Christchurch',
target: null },
];
// Per-task audit slices
const TASK_AUDIT = {
t1: [
{ at: '2026-05-08T08:42:00', actor: 'rod', action: 'created via iMessage',
detail: '"Hey, create new task for Lani — call back Mrs. Patel about the Hilux quote, she wants confirmation before she books"' },
{ at: '2026-05-08T08:43:00', actor: 'system', action: 'priority set to High', detail: 'Inferred from "wants confirmation"' },
{ at: '2026-05-08T08:45:00', actor: 'lani', action: 'opened', detail: '' },
],
t11: [
{ at: '2026-05-08T09:12:00', actor: 'lani', action: 'assigned to ROD', detail: 'flagged for sign-off before send' },
{ at: '2026-05-08T09:14:00', actor: 'lani', action: 'added note', detail: 'Customer wants the work done before Mother\'s Day weekend.' },
],
};
window.SEED_TASKS = SEED_TASKS;
window.SEED_AUDIT = SEED_AUDIT;
window.TASK_AUDIT = TASK_AUDIT;
+314
View File
@@ -0,0 +1,314 @@
// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM)
// Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS,
// persisted to localStorage on every write, and exposed as window.DashyDB.
window.DashyDB = (function () {
const LS_KEY = 'dashy.db.v1';
let db = null;
let SQL = null;
const listeners = new Set();
let ready = false;
const readyWaiters = [];
// -- schema -----------------------------------------------------------
const SCHEMA = `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL,
hue INTEGER NOT NULL,
initials TEXT NOT NULL,
email TEXT,
phone TEXT,
photo TEXT,
password_hash TEXT,
account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
assignee_id TEXT NOT NULL REFERENCES users(id),
added_by TEXT NOT NULL,
priority TEXT NOT NULL CHECK (priority IN ('low','med','high')),
source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')),
status TEXT NOT NULL DEFAULT 'open',
added_at TEXT NOT NULL,
due_at TEXT,
reminder_at TEXT
);
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE TABLE task_tags (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (task_id, tag)
);
CREATE TABLE task_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
summary TEXT NOT NULL,
target TEXT
);
CREATE INDEX idx_audit_at ON audit_log(at);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
device TEXT NOT NULL,
location TEXT,
last_active TEXT NOT NULL
);
`;
function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); }
function persist() {
try {
const bytes = db.export();
const b64 = btoa(String.fromCharCode(...bytes));
localStorage.setItem(LS_KEY, b64);
} catch(e) { console.warn('DashyDB persist failed', e); }
bumpListeners();
}
function loadFromLS() {
const b64 = localStorage.getItem(LS_KEY);
if (!b64) return null;
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function seed() {
db.exec('BEGIN');
USERS.forEach(u => {
const isAdmin = u.id === 'rod' || u.id === 'ayron';
db.run(
'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)',
[u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184',
'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard']
);
});
SEED_TASKS.forEach(t => {
db.run(
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt]
);
(t.tags || []).forEach(tag => {
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
});
});
SEED_AUDIT.forEach(a => {
db.run(
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
[a.id, a.at, a.actor, a.action, a.summary, a.target]
);
});
// sample notes
db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')");
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')");
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')");
db.exec('COMMIT');
}
async function init() {
SQL = await window.initSqlJs({
locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f
});
const existing = loadFromLS();
if (existing) {
db = new SQL.Database(existing);
// migration: add account_type if missing
try {
const cols = query("PRAGMA table_info(users)").map(c => c.name);
if (!cols.includes('account_type')) {
db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'");
db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')");
persist();
}
} catch(e) { console.warn('migration failed', e); }
} else {
db = new SQL.Database();
db.exec(SCHEMA);
seed();
persist();
}
ready = true;
readyWaiters.splice(0).forEach(fn => fn());
bumpListeners();
}
function whenReady() {
if (ready) return Promise.resolve();
return new Promise(r => readyWaiters.push(r));
}
// -- query helpers ----------------------------------------------------
function rowsFrom(stmt) {
const out = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
}
function query(sql, params = []) {
if (!db) return [];
const stmt = db.prepare(sql);
if (params.length) stmt.bind(params);
return rowsFrom(stmt);
}
function exec(sql, params = []) {
if (!db) return;
db.run(sql, params);
persist();
}
// -- domain ops -------------------------------------------------------
function listTasks() {
const rows = query(`
SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv
FROM tasks t
LEFT JOIN task_tags tt ON tt.task_id = t.id
GROUP BY t.id
ORDER BY t.added_at DESC
`);
return rows.map(r => ({
id: r.id, title: r.title, description: r.description,
assignee: r.assignee_id, addedBy: r.added_by,
priority: r.priority, source: r.source, status: r.status,
addedAt: r.added_at,
tags: r.tags_csv ? r.tags_csv.split('|') : []
}));
}
function listAudit() {
return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC');
}
function listUsers() {
return query('SELECT * FROM users ORDER BY rowid ASC');
}
function moveTask(taskId, toUserId, by) {
exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]);
addAudit({
actor: by, action: 'task_moved',
summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name,
target: taskId
});
}
function setPriority(taskId, p) {
exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]);
}
function createTask(t) {
exec(
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt]
);
(t.tags || []).forEach(tag => {
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
});
persist();
}
function addAudit(row) {
const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5);
exec(
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
[id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null]
);
}
function updateUser(userId, edits) {
const fields = []; const vals = [];
if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); }
if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); }
if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); }
if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); }
if (!fields.length) return;
vals.push(userId);
exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals);
}
function createUser(u) {
const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5));
const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase();
const hue = u.hue || Math.floor(Math.random() * 360);
exec(
'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)',
[id, u.name, u.role || 'Team member', hue, initials,
u.email || (id + '@murchison-auto.co'), u.phone || '',
u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)]
);
return id;
}
function deleteUser(userId) {
// reassign any tasks to ROD before delete
exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]);
exec('DELETE FROM users WHERE id = ?', [userId]);
}
function tableNames() {
return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name);
}
function tableInfo(name) {
return query(`PRAGMA table_info(${name})`);
}
function rowCount(name) {
const r = query(`SELECT COUNT(*) AS c FROM ${name}`);
return r[0] ? r[0].c : 0;
}
function exportFile() {
const bytes = db.export();
return new Blob([bytes], { type: 'application/x-sqlite3' });
}
function reset() {
localStorage.removeItem(LS_KEY);
db = new SQL.Database();
db.exec(SCHEMA);
seed();
persist();
}
function rawExec(sql) {
// returns array of {columns, values} for SELECTs, or empty for writes
const res = db.exec(sql);
persist();
return res;
}
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
return {
init, whenReady, query, exec, rawExec,
listTasks, listAudit, listUsers,
createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit,
tableNames, tableInfo, rowCount, exportFile, reset, subscribe,
get isReady() { return ready; },
};
})();
+1057
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"created": "2026-05-08T02:04:31.986Z",
"modified": "2026-05-08T02:04:42.961Z",
"objects": []
}
+1070
View File
File diff suppressed because it is too large Load Diff
+568
View File
@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// Hide the toggle until the host has actually enabled the rail (the
// __omelette_rail_enabled window message, posted only when the
// omelette_deck_rail_enabled flag is on for this user). The initial read
// covers TweaksPanel mounting after the message already arrived; the
// listener covers the common case of mounting first.
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});