diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..0193a69
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,13 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(ssh optical-dev:*)",
+ "mcp__21st-dev-magic__21st_magic_component_inspiration",
+ "Bash(node --check /Volumes/SSD/Projects/Oliver/cc-dashboard/src/static/js/pages/keys.js)",
+ "Bash(CC_API_KEY=cc_YcRD4q8NqZanghwFA9z7b-ZgXf57vgBcKpjhlOM_Jd0 CC_SERVER=https://optical-dev.oliver.solutions/cc-dashboard CC_ROOT_PATH=/Volumes/SSD/Projects/Oliver python3 /Users/aimpress/.claude/cc-collector.py 2>&1)",
+ "Bash(bash deploy.sh)",
+ "Bash(rm -f ~/.claude/.cc-collector-state.json)",
+ "Bash(CC_API_KEY=cc_YcRD4q8NqZanghwFA9z7b-ZgXf57vgBcKpjhlOM_Jd0 CC_SERVER=https://optical-dev.oliver.solutions/cc-dashboard CC_ROOT_PATH=/Volumes/SSD/Projects/Oliver CC_LOOKBACK=8760 python3 /Users/aimpress/.claude/cc-collector.py)"
+ ]
+ }
+}
diff --git a/alembic/versions/0002_project_metadata.py b/alembic/versions/0002_project_metadata.py
new file mode 100644
index 0000000..9dff719
--- /dev/null
+++ b/alembic/versions/0002_project_metadata.py
@@ -0,0 +1,25 @@
+"""add client, job_number, repo_url to projects
+
+Revision ID: 0002
+Revises: 0001
+Create Date: 2026-03-26
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0002"
+down_revision = "0001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("projects", sa.Column("client", sa.String(255), nullable=False, server_default=""))
+ op.add_column("projects", sa.Column("job_number", sa.String(100), nullable=False, server_default=""))
+ op.add_column("projects", sa.Column("repo_url", sa.String(500), nullable=False, server_default=""))
+
+
+def downgrade():
+ op.drop_column("projects", "repo_url")
+ op.drop_column("projects", "job_number")
+ op.drop_column("projects", "client")
diff --git a/src/models.py b/src/models.py
index bf438dd..29ad685 100644
--- a/src/models.py
+++ b/src/models.py
@@ -57,6 +57,9 @@ class Project(Base):
slug: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
root_path: Mapped[str] = mapped_column(String(500), default="")
+ client: Mapped[str] = mapped_column(String(255), default="")
+ job_number: Mapped[str] = mapped_column(String(100), default="")
+ repo_url: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="projects")
diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py
index 56cc385..dc3d765 100644
--- a/src/routers/dashboard.py
+++ b/src/routers/dashboard.py
@@ -125,6 +125,9 @@ async def projects_overview(
proj_data[pid] = {
"project_id": pid,
"display_name": project.display_name,
+ "client": project.client or "",
+ "job_number": project.job_number or "",
+ "repo_url": project.repo_url or "",
"total_hours": 0.0,
"session_count": 0,
"days": set(),
@@ -143,6 +146,9 @@ async def projects_overview(
ProjectHours(
project_id=v["project_id"],
display_name=v["display_name"],
+ client=v["client"],
+ job_number=v["job_number"],
+ repo_url=v["repo_url"],
total_hours=round(v["total_hours"], 2),
session_count=v["session_count"],
working_days=len(v["days"]),
diff --git a/src/routers/projects.py b/src/routers/projects.py
index 305b62e..9e8a597 100644
--- a/src/routers/projects.py
+++ b/src/routers/projects.py
@@ -30,6 +30,12 @@ async def update_project(
raise HTTPException(status_code=404, detail="Project not found")
if "display_name" in body:
project.display_name = str(body["display_name"])[:255]
+ if "client" in body:
+ project.client = str(body["client"])[:255]
+ if "job_number" in body:
+ project.job_number = str(body["job_number"])[:100]
+ if "repo_url" in body:
+ project.repo_url = str(body["repo_url"])[:500]
await db.commit()
await db.refresh(project)
return project
diff --git a/src/schemas.py b/src/schemas.py
index edbb1f1..d1ff511 100644
--- a/src/schemas.py
+++ b/src/schemas.py
@@ -124,6 +124,9 @@ class ProjectHours(BaseModel):
session_count: int
working_days: int
last_active: date | None
+ client: str = ""
+ job_number: str = ""
+ repo_url: str = ""
class DailyPoint(BaseModel):
@@ -179,6 +182,9 @@ class ProjectOut(BaseModel):
slug: str
display_name: str
root_path: str
+ client: str = ""
+ job_number: str = ""
+ repo_url: str = ""
created_at: datetime
model_config = {"from_attributes": True}
diff --git a/src/static/css/app.css b/src/static/css/app.css
index e87562c..e501feb 100644
--- a/src/static/css/app.css
+++ b/src/static/css/app.css
@@ -360,6 +360,27 @@ td .badge, .badge {
.badge-success { background: rgba(34,197,94,.12); color: var(--success); }
.badge-danger { background: rgba(239,68,68,.12); color: var(--danger); }
+/* ── Inline editable cells ─────────────────────────── */
+.editable-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ border-radius: 4px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ transition: background .15s;
+}
+.editable-cell:hover { background: var(--surface2); }
+.editable-cell .edit-hint {
+ opacity: 0;
+ font-size: 11px;
+ color: var(--text-muted);
+ transition: opacity .15s;
+ flex-shrink: 0;
+}
+.editable-cell:hover .edit-hint { opacity: 1; }
+
/* ── Buttons ───────────────────────────────────────── */
.btn {
display: inline-flex;
diff --git a/src/static/js/pages/projects.js b/src/static/js/pages/projects.js
index 6cd1190..ea33806 100644
--- a/src/static/js/pages/projects.js
+++ b/src/static/js/pages/projects.js
@@ -1,48 +1,197 @@
const ProjectsPage = (() => {
+ let _projects = [];
+
async function render(container) {
container.innerHTML = `
`;
try {
- const projects = await Api.get('/api/dashboard/projects');
- container.innerHTML = `
-
-
-
-
-
-
- | Project |
- Total Hours |
- Sessions |
- Working Days |
- Last Active |
- |
-
-
-
- ${projects.map(p => `
-
- | ${p.display_name} |
- ${p.total_hours.toFixed(1)}h |
- ${p.session_count} |
- ${p.working_days} |
- ${p.last_active || '—'} |
-
-
- |
-
- `).join('')}
-
-
-
-
- `;
+ _projects = await Api.get('/api/dashboard/projects');
+ _renderTable(container);
} catch (e) {
container.innerHTML = ``;
}
}
+ function _renderTable(container) {
+ container.innerHTML = `
+
+
+
+
+
+
+ | Project |
+ Client |
+ OMG Job # |
+ Repo |
+ Hours |
+ Sessions |
+ Days |
+ Last Active |
+ |
+
+
+
+ ${_projects.map(p => _rowHtml(p)).join('')}
+
+
+
+
+ `;
+ _bindEvents(container);
+ }
+
+ function _rowHtml(p) {
+ const repoBtn = p.repo_url
+ ? `
+ ↗${_repoLabel(p.repo_url)}
+
+ `
+ : ``;
+
+ return `
+
+ |
+
+ ${_esc(p.display_name)}
+ ✎
+
+ |
+
+
+ ${p.client ? _esc(p.client) : '—'}
+ ✎
+
+ |
+
+
+ ${p.job_number ? _esc(p.job_number) : '—'}
+ ✎
+
+ |
+
+
+ ${repoBtn}
+
+ |
+ ${p.total_hours.toFixed(1)}h |
+ ${p.session_count} |
+ ${p.working_days} |
+ ${p.last_active || '—'} |
+
+
+ |
+
+ `;
+ }
+
+ function _repoLabel(url) {
+ try {
+ const u = new URL(url);
+ return (u.hostname + u.pathname).replace(/\/$/, '').replace('www.', '');
+ } catch {
+ return url.slice(0, 30);
+ }
+ }
+
+ function _esc(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+ }
+
+ function _bindEvents(container) {
+ // Inline editable cells
+ container.querySelectorAll('.editable-cell').forEach(cell => {
+ cell.onclick = () => _startEdit(cell);
+ });
+
+ // Repo edit buttons
+ container.querySelectorAll('.repo-edit-btn').forEach(btn => {
+ btn.onclick = () => _editRepo(btn.dataset.id, container);
+ });
+ }
+
+ function _startEdit(cell) {
+ if (cell.querySelector('input')) return; // already editing
+ const field = cell.dataset.field;
+ const id = cell.dataset.id;
+ const proj = _projects.find(p => p.project_id === id);
+ const current = proj ? (proj[field] || '') : '';
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = current;
+ input.style.cssText = 'background:var(--surface2);border:1px solid var(--accent);border-radius:4px;color:var(--text);padding:3px 7px;font-size:13px;font-family:inherit;width:100%;min-width:120px;outline:none';
+
+ cell.innerHTML = '';
+ cell.appendChild(input);
+ input.focus();
+ input.select();
+
+ const save = async () => {
+ const val = input.value.trim();
+ if (val === current) { _renderTable(cell.closest('.page').parentElement); return; }
+ try {
+ await Api.patch('/api/projects/' + id, { [field]: val });
+ const p = _projects.find(p => p.project_id === id);
+ if (p) p[field] = val;
+ } catch (e) {
+ alert('Save failed: ' + e.message);
+ }
+ _renderTable(cell.closest('.page').parentElement);
+ };
+
+ input.onblur = save;
+ input.onkeydown = e => {
+ if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
+ if (e.key === 'Escape') { _renderTable(cell.closest('.page').parentElement); }
+ };
+ }
+
+ function _editRepo(id, container) {
+ const proj = _projects.find(p => p.project_id === id);
+ const current = proj ? (proj.repo_url || '') : '';
+
+ // Build a small inline input in the repo cell
+ const repoCell = container.querySelector(`.repo-cell[data-id="${id}"]`);
+ if (!repoCell) return;
+
+ const div = repoCell.querySelector('div');
+ div.innerHTML = '';
+
+ const input = document.createElement('input');
+ input.type = 'url';
+ input.value = current;
+ input.placeholder = 'https://github.com/org/repo';
+ input.style.cssText = 'background:var(--surface2);border:1px solid var(--accent);border-radius:4px;color:var(--text);padding:3px 7px;font-size:12px;font-family:inherit;width:180px;outline:none';
+
+ const saveBtn = document.createElement('button');
+ saveBtn.textContent = 'Save';
+ saveBtn.className = 'btn btn-primary btn-sm';
+ saveBtn.style.fontSize = '11px';
+
+ div.appendChild(input);
+ div.appendChild(saveBtn);
+ input.focus();
+
+ const save = async () => {
+ const val = input.value.trim();
+ try {
+ await Api.patch('/api/projects/' + id, { repo_url: val });
+ if (proj) proj.repo_url = val;
+ } catch (e) {
+ alert('Save failed: ' + e.message);
+ }
+ _renderTable(container);
+ };
+
+ saveBtn.onclick = save;
+ input.onkeydown = e => {
+ if (e.key === 'Enter') { e.preventDefault(); save(); }
+ if (e.key === 'Escape') { _renderTable(container); }
+ };
+ }
+
return { render };
})();