From 4d69a4ac64fc668ce1203adfcea36695b7873f37 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 26 Mar 2026 15:08:14 +0000 Subject: [PATCH] feat: project metadata fields (client, job#, repo) with inline editing + time calc audit --- .claude/settings.local.json | 13 ++ alembic/versions/0002_project_metadata.py | 25 +++ src/models.py | 3 + src/routers/dashboard.py | 6 + src/routers/projects.py | 6 + src/schemas.py | 6 + src/static/css/app.css | 21 ++ src/static/js/pages/projects.js | 223 ++++++++++++++++++---- 8 files changed, 266 insertions(+), 37 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 alembic/versions/0002_project_metadata.py 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 = `
Loading projects…
`; try { - const projects = await Api.get('/api/dashboard/projects'); - container.innerHTML = ` -
-
-

Projects

- ${projects.length} project${projects.length !== 1 ? 's' : ''} -
-
- - - - - - - - - - - - - ${projects.map(p => ` - - - - - - - - - `).join('')} - -
ProjectTotal HoursSessionsWorking DaysLast Active
${p.display_name}${p.total_hours.toFixed(1)}h${p.session_count}${p.working_days}${p.last_active || '—'} - -
-
-
- `; + _projects = await Api.get('/api/dashboard/projects'); + _renderTable(container); } catch (e) { container.innerHTML = `
⚠️
${e.message}
`; } } + function _renderTable(container) { + container.innerHTML = ` +
+
+

Projects

+ ${_projects.length} project${_projects.length !== 1 ? 's' : ''} +
+
+ + + + + + + + + + + + + + + + ${_projects.map(p => _rowHtml(p)).join('')} + +
ProjectClientOMG Job #RepoHoursSessionsDaysLast Active
+
+
+ `; + _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 }; })();