// Tenney North Park — Project DP (TN-10) component. // Standalone data product opened by the Project tile (no dashboard card). // Same DPA anatomy as every other product: Artifact / Data / Control / Context. // Artifact = interactive tracker (Tasks · Timeline · Calendar lenses on one // task table). Content lives in tabs/tenney_project_content.js (window.TN_PROJECT). function TenneyProject() { const P = window.TN_PROJECT; const [face, setFace] = React.useState('artifact'); // artifact | data | control | context const [lens, setLens] = React.useState('tasks'); // tasks | timeline | calendar const [q, setQ] = React.useState(''); const [wsF, setWsF] = React.useState(''); const [stF, setStF] = React.useState(''); const [detail, setDetail] = React.useState(null); // task id | 'new' | null const [collapsed, setCollapsed] = React.useState({}); const [selDay, setSelDay] = React.useState(null); const [tasks, setTasks] = React.useState(P.tasks); // Framework CSS (sheet renderer etc.) — same injection as the dashboard. React.useEffect(() => { if (!document.getElementById('cx-dash-style') && window.CX_DASH) { const st = document.createElement('style'); st.id = 'cx-dash-style'; st.textContent = window.CX_DASH.css; document.head.appendChild(st); } }, []); const today = new Date(P.today + 'T00:00:00'); const parseD = (d) => new Date(d + 'T00:00:00'); const fmtD = (d) => parseD(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const overdue = (t) => t.status !== 'Complete' && parseD(t.due) < today; const visible = tasks.filter((t) => (!q || t.name.toLowerCase().includes(q.toLowerCase()) || (P.people[t.owner] || '').toLowerCase().includes(q.toLowerCase())) && (!wsF || t.ws === wsF) && (!stF || t.status === stF)); const detailTask = typeof detail === 'number' ? tasks.find((t) => t.id === detail) : null; return (
{[['artifact', 'Artifact'], ['data', 'Data'], ['control', 'Control'], ['context', 'Context']].map(([k, l]) => )}
{face === 'artifact' && ( )} {face === 'data' && } {face === 'control' && } {face === 'context' && } {(detailTask || detail === 'new') && ( setDetail(null)} onAdd={(t) => { setTasks((xs) => [...xs, t]); setDetail(null); }} /> )}
); } /* ── metrics + lens tabs + filters + the three lenses ── */ function PjArtifact(props) { const { P, tasks, visible, lens, setLens, q, setQ, wsF, setWsF, stF, setStF, openDetail } = props; const pct = Math.round(tasks.reduce((s, t) => s + t.pct, 0) / (tasks.length || 1)); const inProg = tasks.filter((t) => t.status === 'In Progress').length; const blocked = tasks.filter((t) => t.status === 'Blocked').length; const nextMs = tasks.filter((t) => t.milestone && t.status !== 'Complete') .sort((a, b) => props.parseD(a.due) - props.parseD(b.due))[0]; return (
Tasks
{tasks.length}
4 workstreams
In Progress
{inProg}
active now
Blocked
{blocked}
needs attention
Complete
{pct}%
weighted
Next Milestone
{nextMs ? props.fmtD(nextMs.due) : '—'}
{nextMs ? nextMs.name : ''}
{[['tasks', 'Tasks'], ['timeline', 'Timeline'], ['calendar', 'Calendar']].map(([k, l]) => )}
setQ(e.target.value)} />
{visible.length === 0 ?
No matching tasks — adjust your search or filters.
: lens === 'tasks' ? : lens === 'timeline' ? : }
); } function PjStatusIcon({ s }) { const common = { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }; if (s === 'Complete') return ; if (s === 'In Progress') return ; if (s === 'Blocked') return ; return ; } function PjPriority({ p }) { const on = '#615D5A', off = '#E7E1DF'; const fills = p === 'High' ? [on, on, on] : p === 'Medium' ? [on, on, off] : [on, off, off]; return ( ); } function PjRow({ t, P, fmtD, overdue, openDetail }) { return (
openDetail(t.id)}> {t.name} {t.milestone && } {fmtD(t.due)} {t.pct}% {t.owner}
); } function PjTasks(props) { const { P, visible, collapsed, setCollapsed } = props; return (
{P.workstreams.map((ws) => { const group = visible.filter((t) => t.ws === ws); if (!group.length) return null; const done = group.filter((t) => t.status === 'Complete').length; const isC = collapsed[ws]; return (
setCollapsed((c) => ({ ...c, [ws]: !c[ws] }))}> {ws} {done}/{group.length} complete
{!isC && group.map((t) => )}
); })}
); } function PjTimeline(props) { const { P, visible, today, parseD, fmtD, openDetail } = props; const ms = visible.filter((t) => t.milestone).sort((a, b) => parseD(a.due) - parseD(b.due)); const min = new Date(Math.min(...visible.map((t) => parseD(t.start)))); const max = new Date(Math.max(...visible.map((t) => parseD(t.due)))); const start = new Date(min); start.setDate(start.getDate() - 4); const end = new Date(max); end.setDate(end.getDate() + 5); const pos = (d) => ((d - start) / (end - start)) * 100; const months = []; const m = new Date(start.getFullYear(), start.getMonth() + 1, 1); while (m <= end) { months.push({ pct: pos(new Date(m)), label: m.toLocaleDateString('en-US', { month: 'short' }) }); m.setMonth(m.getMonth() + 1); } return (
Milestones
{ms.map((t) =>
openDetail(t.id)}>
{fmtD(t.due)}
{t.name}
{t.ws} · {t.status}
)}
Timeline
{months.map((mk, i) => {mk.label})}
{P.workstreams.map((ws) => { const group = visible.filter((t) => t.ws === ws); if (!group.length) return null; return (
{ws}
{months.map((mk, i) => )}
{group.map((t) => { const s = Math.max(pos(parseD(t.start)), 0); const e = Math.min(pos(parseD(t.due)), 100); const w = Math.max(e - s, 0.8); const isPoint = t.start === t.due; return (
{t.name}
{months.map((mk, i) => )} {!isPoint && ( openDetail(t.id)}> )} {t.milestone && openDetail(t.id)}>}
); })}
); })}
Progress Planned Milestone Today
); } function PjCalendar(props) { const { visible, today, parseD, fmtD, selDay, setSelDay, openDetail } = props; const [calStart, setCalStart] = React.useState(() => new Date(2026, 0, 1)); const dayKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const byDay = {}; visible.forEach((t) => { (byDay[t.due] = byDay[t.due] || []).push(t); }); const months = [0, 1, 2].map((i) => new Date(calStart.getFullYear(), calStart.getMonth() + i, 1)); let list = visible.slice().sort((a, b) => parseD(a.due) - parseD(b.due)); if (selDay) list = list.filter((t) => t.due === selDay); return (
{months[0].toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} – {months[2].toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
{months.map((mo) => { const year = mo.getFullYear(), month = mo.getMonth(); const firstDow = new Date(year, month, 1).getDay(); const days = new Date(year, month + 1, 0).getDate(); const cells = []; for (let i = 0; i < firstDow; i++) cells.push(); for (let d = 1; d <= days; d++) { const date = new Date(year, month, d); const key = dayKey(date); const items = byDay[key] || []; const hasMs = items.some((t) => t.milestone); const dots = Math.min(items.filter((t) => !t.milestone).length, 3); cells.push( setSelDay(selDay === key ? null : key) : undefined}> {d} {hasMs && } {Array.from({ length: dots }).map((_, i) => )} ); } return (
{mo.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => {d})}
{cells}
); })}
{selDay && ( )}
{list.length === 0 ?
Nothing due on this day.
: list.map((t) => )}
); } window.TenneyProject = TenneyProject; // Shared with tenney_project_faces.jsx (separate Babel scope): Object.assign(window, { PjStatusIcon, PjPriority });