sanjang 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/app.js +304 -44
- package/dashboard/index.html +43 -6
- package/dashboard/style.css +306 -0
- package/dist/lib/server.js +69 -2
- package/package.json +1 -1
package/dashboard/app.js
CHANGED
|
@@ -22,6 +22,29 @@ let currentWorkspace = null;
|
|
|
22
22
|
/** @type {number|null} polling interval for workspace changes */
|
|
23
23
|
let wsPollingInterval = null;
|
|
24
24
|
|
|
25
|
+
const SHERPA_QUOTES = [
|
|
26
|
+
"요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
|
|
27
|
+
"'간단한 건데~' 그 말이 제일 무섭댜",
|
|
28
|
+
"CEO가 데모 전날 피드백 줬어유. 다 뜯으래유.",
|
|
29
|
+
"이거 금방 되쥬? 네... 산도 금방 오르쥬.",
|
|
30
|
+
"스펙 확정이랬는디... 또 바뀌었댜",
|
|
31
|
+
"우선순위 1이 열두 개여유. 산봉우리가 열두 개.",
|
|
32
|
+
"디자인 안 나왔는디 개발 먼저 하래유",
|
|
33
|
+
"어제 합의한 거 오늘 모른댜. 나도 모르겠댜.",
|
|
34
|
+
"v1인디 왜 v3 기능을 넣으래유",
|
|
35
|
+
"PRD 다섯 번째 바뀌는 중이여유. 난 여그 서있쥬.",
|
|
36
|
+
"아 그거 빼자고 한 거... 다시 넣는댜. 그러쥬 뭐.",
|
|
37
|
+
"고객이 원한댜. 근디 고객이 누구여.",
|
|
38
|
+
"MVP라며유. M이 자꾸 빠져유.",
|
|
39
|
+
"이번엔 진짜 마지막 수정이래유. 네 번째 마지막.",
|
|
40
|
+
"배포하면 쉬는 거 아니었어유?",
|
|
41
|
+
"'빨리 한번 해봐유' 3주째 하는 중이쥬.",
|
|
42
|
+
"피벗이래유. 하산 아니래유. 글쎄유.",
|
|
43
|
+
"개발 부채가 배낭보다 무겁댜. 그래도 가야쥬.",
|
|
44
|
+
"야근이여유? 셰르파는 원래 이러쥬 뭐.",
|
|
45
|
+
"롤백한 거 아무도 모르쥬? 나도 모른댜.",
|
|
46
|
+
];
|
|
47
|
+
|
|
25
48
|
// ---------------------------------------------------------------------------
|
|
26
49
|
// Utility
|
|
27
50
|
// ---------------------------------------------------------------------------
|
|
@@ -1280,7 +1303,7 @@ function renderBlocks(files) {
|
|
|
1280
1303
|
}
|
|
1281
1304
|
container.innerHTML = files.map(f => {
|
|
1282
1305
|
const type = f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del';
|
|
1283
|
-
return `<div class="ws-block ws-block-${type}" title="${f.path}"></div>`;
|
|
1306
|
+
return `<div class="ws-block ws-block-${type}" title="${escHtml(f.path)}"></div>`;
|
|
1284
1307
|
}).join('');
|
|
1285
1308
|
container.classList.toggle('ws-blocks-wobble', files.length >= 5);
|
|
1286
1309
|
}
|
|
@@ -1614,48 +1637,6 @@ window.togglePanel = function() {
|
|
|
1614
1637
|
// ---------------------------------------------------------------------------
|
|
1615
1638
|
|
|
1616
1639
|
|
|
1617
|
-
async function loadSuggestions() {
|
|
1618
|
-
const section = document.getElementById('portal-suggestions-section');
|
|
1619
|
-
const list = document.getElementById('portal-suggestions');
|
|
1620
|
-
if (!section || !list) return;
|
|
1621
|
-
|
|
1622
|
-
try {
|
|
1623
|
-
const suggestions = await api('GET', '/api/suggestions');
|
|
1624
|
-
if (!suggestions || suggestions.length === 0) {
|
|
1625
|
-
section.style.display = 'none';
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
// Exclude "recent" commits (noise) and deduplicate with "이어하기"
|
|
1630
|
-
const workTitles = new Set([...playgrounds.values()].map(p => p.branch));
|
|
1631
|
-
const filtered = suggestions
|
|
1632
|
-
.filter(item => item.type !== 'recent')
|
|
1633
|
-
.filter(item => !workTitles.has(item.action))
|
|
1634
|
-
.slice(0, 5);
|
|
1635
|
-
|
|
1636
|
-
if (filtered.length === 0) {
|
|
1637
|
-
section.style.display = 'none';
|
|
1638
|
-
return;
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
const iconMap = { issue: '🔵', pr: '🟡' };
|
|
1642
|
-
list.innerHTML = filtered.map(item => {
|
|
1643
|
-
const icon = iconMap[item.type] || '⚪';
|
|
1644
|
-
return `
|
|
1645
|
-
<div class="portal-work-item">
|
|
1646
|
-
<div class="portal-work-left">
|
|
1647
|
-
<span class="portal-work-icon">${icon}</span>
|
|
1648
|
-
<div>
|
|
1649
|
-
<div class="portal-work-title">${escHtml(item.title)}</div>
|
|
1650
|
-
</div>
|
|
1651
|
-
</div>
|
|
1652
|
-
</div>`;
|
|
1653
|
-
}).join('');
|
|
1654
|
-
section.style.display = '';
|
|
1655
|
-
} catch {
|
|
1656
|
-
section.style.display = 'none';
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
1640
|
async function loadPortal() {
|
|
1660
1641
|
const workList = document.getElementById('portal-work');
|
|
1661
1642
|
if (!workList) return;
|
|
@@ -1863,6 +1844,284 @@ function showOnboarding() {
|
|
|
1863
1844
|
show();
|
|
1864
1845
|
}
|
|
1865
1846
|
|
|
1847
|
+
// ---------------------------------------------------------------------------
|
|
1848
|
+
// Sherpa Quote Rotation
|
|
1849
|
+
// ---------------------------------------------------------------------------
|
|
1850
|
+
|
|
1851
|
+
function startSherpaQuotes() {
|
|
1852
|
+
const el = document.getElementById('sherpa-quote');
|
|
1853
|
+
if (!el) return;
|
|
1854
|
+
|
|
1855
|
+
// Shuffle array (Fisher-Yates)
|
|
1856
|
+
let quotes = [...SHERPA_QUOTES];
|
|
1857
|
+
for (let i = quotes.length - 1; i > 0; i--) {
|
|
1858
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
1859
|
+
[quotes[i], quotes[j]] = [quotes[j], quotes[i]];
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
let idx = 0;
|
|
1863
|
+
// Set initial random quote
|
|
1864
|
+
el.textContent = quotes[idx];
|
|
1865
|
+
|
|
1866
|
+
setInterval(() => {
|
|
1867
|
+
el.style.opacity = '0';
|
|
1868
|
+
setTimeout(() => {
|
|
1869
|
+
idx++;
|
|
1870
|
+
if (idx >= quotes.length) {
|
|
1871
|
+
// Reshuffle
|
|
1872
|
+
for (let i = quotes.length - 1; i > 0; i--) {
|
|
1873
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
1874
|
+
[quotes[i], quotes[j]] = [quotes[j], quotes[i]];
|
|
1875
|
+
}
|
|
1876
|
+
idx = 0;
|
|
1877
|
+
}
|
|
1878
|
+
el.textContent = quotes[idx];
|
|
1879
|
+
el.style.opacity = '1';
|
|
1880
|
+
}, 500);
|
|
1881
|
+
}, 8000);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ---------------------------------------------------------------------------
|
|
1885
|
+
// Activity Trail
|
|
1886
|
+
// ---------------------------------------------------------------------------
|
|
1887
|
+
|
|
1888
|
+
async function loadActivityTrail() {
|
|
1889
|
+
const container = document.getElementById('activity-trail');
|
|
1890
|
+
const section = document.getElementById('activity-trail-section');
|
|
1891
|
+
if (!container || !section) return;
|
|
1892
|
+
|
|
1893
|
+
try {
|
|
1894
|
+
const data = await api('GET', '/api/activity');
|
|
1895
|
+
if (!data || !data.daily || data.daily.length === 0) {
|
|
1896
|
+
section.style.display = 'none';
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
section.style.display = '';
|
|
1901
|
+
const days = data.daily;
|
|
1902
|
+
const prs = data.mergedPrs || [];
|
|
1903
|
+
const streak = data.streak || 0;
|
|
1904
|
+
const totalCommits = days.reduce((s, d) => s + d.commits, 0);
|
|
1905
|
+
|
|
1906
|
+
// Calculate terrain heights
|
|
1907
|
+
const maxCommits = Math.max(...days.map(d => d.commits), 1);
|
|
1908
|
+
const svgW = 680, svgH = 180;
|
|
1909
|
+
const ground = 150, ceiling = 35;
|
|
1910
|
+
const dayW = (svgW - 40) / days.length; // 20px padding each side
|
|
1911
|
+
|
|
1912
|
+
// Map commits to Y coordinate (more commits = higher = lower Y)
|
|
1913
|
+
const heights = days.map(d => {
|
|
1914
|
+
if (d.commits === 0) return ground;
|
|
1915
|
+
return ground - ((d.commits / maxCommits) * (ground - ceiling));
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// Build stepped polyline points (pixel staircase)
|
|
1919
|
+
let terrainPoints = `20,${ground} `;
|
|
1920
|
+
heights.forEach((h, i) => {
|
|
1921
|
+
const x = 20 + i * dayW;
|
|
1922
|
+
const xEnd = 20 + (i + 1) * dayW;
|
|
1923
|
+
terrainPoints += `${x},${h} ${xEnd},${h} `;
|
|
1924
|
+
});
|
|
1925
|
+
terrainPoints += `${20 + days.length * dayW},${ground}`;
|
|
1926
|
+
|
|
1927
|
+
// PR merge dates set for lookup
|
|
1928
|
+
const prByDate = {};
|
|
1929
|
+
prs.forEach(pr => {
|
|
1930
|
+
const date = pr.mergedAt.split('T')[0];
|
|
1931
|
+
if (!prByDate[date]) prByDate[date] = [];
|
|
1932
|
+
prByDate[date].push(pr);
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
// Build pixel decorations
|
|
1936
|
+
let decorations = '';
|
|
1937
|
+
|
|
1938
|
+
// Stars
|
|
1939
|
+
const starPositions = [[45,8],[130,14],[220,6],[350,10],[480,5],[560,16],[640,8]];
|
|
1940
|
+
starPositions.forEach(([x,y]) => {
|
|
1941
|
+
decorations += `<rect x="${x}" y="${y}" width="2" height="2" fill="#fff" opacity="${0.2 + Math.random() * 0.3}"/>`;
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
// Snow on high peaks (top 20%)
|
|
1945
|
+
const threshold = ceiling + (ground - ceiling) * 0.3;
|
|
1946
|
+
heights.forEach((h, i) => {
|
|
1947
|
+
if (h < threshold) {
|
|
1948
|
+
const x = 20 + i * dayW + dayW / 2 - 3;
|
|
1949
|
+
decorations += `<rect x="${x}" y="${h}" width="6" height="2" fill="rgba(255,255,255,0.2)"/>`;
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// Trees on low terrain
|
|
1954
|
+
heights.forEach((h, i) => {
|
|
1955
|
+
if (h > ground - 30 && h < ground && days[i].commits > 0 && Math.random() > 0.7) {
|
|
1956
|
+
const x = 20 + i * dayW + dayW / 2;
|
|
1957
|
+
decorations += `
|
|
1958
|
+
<rect x="${x}" y="${h - 8}" width="2" height="8" fill="#3d5a3d"/>
|
|
1959
|
+
<rect x="${x - 2}" y="${h - 12}" width="6" height="4" fill="#4a7a4a"/>
|
|
1960
|
+
<rect x="${x}" y="${h - 16}" width="2" height="4" fill="#5b8c5a"/>`;
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
// Tents on rest days (0 commits, not first/last)
|
|
1965
|
+
heights.forEach((h, i) => {
|
|
1966
|
+
if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
|
|
1967
|
+
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
1968
|
+
decorations += `
|
|
1969
|
+
<rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
|
|
1970
|
+
<rect x="${x + 1}" y="${ground - 6}" width="6" height="2" fill="#6b7394"/>
|
|
1971
|
+
<rect x="${x}" y="${ground - 4}" width="8" height="2" fill="#4a5170"/>`;
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
// PR campfires + tooltip triggers
|
|
1976
|
+
let prMarkers = '';
|
|
1977
|
+
days.forEach((d, i) => {
|
|
1978
|
+
const datePrs = prByDate[d.date];
|
|
1979
|
+
if (datePrs && datePrs.length > 0) {
|
|
1980
|
+
const x = 20 + i * dayW + dayW / 2 - 4;
|
|
1981
|
+
const h = heights[i];
|
|
1982
|
+
const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
|
|
1983
|
+
prMarkers += `
|
|
1984
|
+
<g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '"')}">
|
|
1985
|
+
<rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
1986
|
+
<rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
|
|
1987
|
+
<rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
|
|
1988
|
+
<rect x="${x}" y="${h - 10}" width="2" height="2" fill="#ffcc00"/>
|
|
1989
|
+
<rect x="${x + 4}" y="${h - 12}" width="2" height="4" fill="#ff9900"/>
|
|
1990
|
+
<rect x="${x + 2}" y="${h - 14}" width="2" height="4" fill="#ffcc00"/>
|
|
1991
|
+
<circle cx="${x + 3}" cy="${h - 8}" r="6" fill="#ff9900" opacity="0.06"/>
|
|
1992
|
+
</g>`;
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
// Coins on top 3 peaks
|
|
1997
|
+
const peakIndices = heights
|
|
1998
|
+
.map((h, i) => ({ h, i }))
|
|
1999
|
+
.sort((a, b) => a.h - b.h)
|
|
2000
|
+
.slice(0, 3);
|
|
2001
|
+
peakIndices.forEach(({ h, i }) => {
|
|
2002
|
+
const x = 20 + i * dayW + dayW / 2 - 3;
|
|
2003
|
+
decorations += `
|
|
2004
|
+
<rect x="${x}" y="${h - 10}" width="6" height="6" fill="#f59e0b" opacity="0.7"/>
|
|
2005
|
+
<rect x="${x + 2}" y="${h - 8}" width="2" height="2" fill="#0a0c11"/>`;
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
// Flag on highest peak
|
|
2009
|
+
const highest = peakIndices[0];
|
|
2010
|
+
if (highest) {
|
|
2011
|
+
const x = 20 + highest.i * dayW + dayW / 2;
|
|
2012
|
+
decorations += `
|
|
2013
|
+
<rect x="${x}" y="${highest.h}" width="2" height="14" fill="#f59e0b"/>
|
|
2014
|
+
<polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Sherpa at today (last position)
|
|
2018
|
+
const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
|
|
2019
|
+
const lastH = heights[heights.length - 1];
|
|
2020
|
+
const sherpaY = lastH - 16;
|
|
2021
|
+
const sherpa = `
|
|
2022
|
+
<g transform="translate(${lastX}, ${sherpaY})">
|
|
2023
|
+
<rect x="2" y="0" width="4" height="2" fill="#5b8c5a"/>
|
|
2024
|
+
<rect x="0" y="2" width="8" height="2" fill="#5b8c5a"/>
|
|
2025
|
+
<rect x="2" y="4" width="4" height="4" fill="#ffcc88"/>
|
|
2026
|
+
<rect x="2" y="8" width="4" height="4" fill="#e74c3c"/>
|
|
2027
|
+
<rect x="0" y="8" width="2" height="2" fill="#e74c3c"/>
|
|
2028
|
+
<rect x="6" y="8" width="2" height="4" fill="#8B6914"/>
|
|
2029
|
+
<rect x="2" y="12" width="2" height="2" fill="#5b4a3a"/>
|
|
2030
|
+
<rect x="4" y="12" width="2" height="2" fill="#5b4a3a"/>
|
|
2031
|
+
</g>`;
|
|
2032
|
+
|
|
2033
|
+
// Week labels
|
|
2034
|
+
let weekLabels = '';
|
|
2035
|
+
const weekSize = 7;
|
|
2036
|
+
for (let w = 0; w < Math.floor(days.length / weekSize); w++) {
|
|
2037
|
+
const x = 20 + (w * weekSize + 3) * dayW;
|
|
2038
|
+
const weeksAgo = Math.floor(days.length / weekSize) - w - 1;
|
|
2039
|
+
const label = weeksAgo === 0 ? '이번 주' : `${weeksAgo}주 전`;
|
|
2040
|
+
weekLabels += `<text x="${x}" y="170" font-size="8" fill="#4a5170" font-family="Outfit,sans-serif" text-anchor="middle">${label}</text>`;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Pixel clouds
|
|
2044
|
+
const clouds = `
|
|
2045
|
+
<g opacity="0.08">
|
|
2046
|
+
<rect x="80" y="20" width="4" height="4" fill="#fff"/>
|
|
2047
|
+
<rect x="84" y="18" width="8" height="4" fill="#fff"/>
|
|
2048
|
+
<rect x="88" y="16" width="4" height="4" fill="#fff"/>
|
|
2049
|
+
<rect x="92" y="18" width="4" height="4" fill="#fff"/>
|
|
2050
|
+
<rect x="76" y="22" width="24" height="4" fill="#fff"/>
|
|
2051
|
+
</g>
|
|
2052
|
+
<g opacity="0.06">
|
|
2053
|
+
<rect x="420" y="14" width="4" height="4" fill="#fff"/>
|
|
2054
|
+
<rect x="424" y="12" width="8" height="4" fill="#fff"/>
|
|
2055
|
+
<rect x="432" y="14" width="4" height="4" fill="#fff"/>
|
|
2056
|
+
<rect x="416" y="18" width="24" height="4" fill="#fff"/>
|
|
2057
|
+
</g>`;
|
|
2058
|
+
|
|
2059
|
+
// PR tooltip container (CSS positioned)
|
|
2060
|
+
const tooltip = `<div class="activity-tooltip" id="activity-tooltip" style="display:none;position:absolute;background:#1c2030;border:1px solid #2a2f42;border-radius:6px;padding:8px 12px;font-size:12px;color:#e4e8f0;pointer-events:none;white-space:nowrap;z-index:10;max-width:320px;box-shadow:0 4px 12px rgba(0,0,0,0.4);"></div>`;
|
|
2061
|
+
|
|
2062
|
+
const svg = `
|
|
2063
|
+
<svg viewBox="0 0 ${svgW} ${svgH}" style="display:block;width:100%;" xmlns="http://www.w3.org/2000/svg">
|
|
2064
|
+
<rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#080a10"/>
|
|
2065
|
+
${clouds}
|
|
2066
|
+
${decorations}
|
|
2067
|
+
<defs>
|
|
2068
|
+
<linearGradient id="trailFill" x1="0" y1="0" x2="0" y2="1">
|
|
2069
|
+
<stop offset="0%" stop-color="#5b8c5a" stop-opacity="0.25"/>
|
|
2070
|
+
<stop offset="100%" stop-color="#5b8c5a" stop-opacity="0.03"/>
|
|
2071
|
+
</linearGradient>
|
|
2072
|
+
</defs>
|
|
2073
|
+
<polygon fill="url(#trailFill)" points="${terrainPoints}"/>
|
|
2074
|
+
<polyline fill="none" stroke="#5b8c5a" stroke-width="2" points="${terrainPoints.split(` ${20 + days.length * dayW},${ground}`)[0]}"/>
|
|
2075
|
+
<line x1="16" y1="${ground}" x2="${svgW - 16}" y2="${ground}" stroke="#1c2030" stroke-width="1"/>
|
|
2076
|
+
${prMarkers}
|
|
2077
|
+
${sherpa}
|
|
2078
|
+
${weekLabels}
|
|
2079
|
+
</svg>`;
|
|
2080
|
+
|
|
2081
|
+
const streakText = streak > 0 ? `🔥 연속 ${streak}일째 등반 중` : '⛺ 오늘은 쉬는 날';
|
|
2082
|
+
const periodText = `최근 4주 · 커밋 ${totalCommits}개 · PR ${prs.length}개`;
|
|
2083
|
+
|
|
2084
|
+
container.innerHTML = `
|
|
2085
|
+
<div style="position:relative;">
|
|
2086
|
+
${svg}
|
|
2087
|
+
${tooltip}
|
|
2088
|
+
</div>
|
|
2089
|
+
<div class="activity-info">
|
|
2090
|
+
<div class="activity-streak">${streakText}</div>
|
|
2091
|
+
<div class="activity-period">${periodText}</div>
|
|
2092
|
+
</div>`;
|
|
2093
|
+
|
|
2094
|
+
// Add PR tooltip hover handlers
|
|
2095
|
+
container.querySelectorAll('.pr-marker').forEach(marker => {
|
|
2096
|
+
marker.style.cursor = 'pointer';
|
|
2097
|
+
marker.addEventListener('mouseenter', (e) => {
|
|
2098
|
+
const tip = document.getElementById('activity-tooltip');
|
|
2099
|
+
if (!tip) return;
|
|
2100
|
+
tip.innerHTML = marker.getAttribute('data-tooltip').split('\n').map(l => escHtml(l)).join('<br>');
|
|
2101
|
+
tip.style.display = 'block';
|
|
2102
|
+
const rect = marker.getBoundingClientRect();
|
|
2103
|
+
const containerRect = container.getBoundingClientRect();
|
|
2104
|
+
let left = rect.left - containerRect.left;
|
|
2105
|
+
// Prevent overflow on right edge
|
|
2106
|
+
const tipWidth = tip.offsetWidth;
|
|
2107
|
+
if (left + tipWidth > containerRect.width) {
|
|
2108
|
+
left = containerRect.width - tipWidth - 8;
|
|
2109
|
+
}
|
|
2110
|
+
if (left < 8) left = 8;
|
|
2111
|
+
tip.style.left = left + 'px';
|
|
2112
|
+
tip.style.top = (rect.top - containerRect.top - 40) + 'px';
|
|
2113
|
+
});
|
|
2114
|
+
marker.addEventListener('mouseleave', () => {
|
|
2115
|
+
const tip = document.getElementById('activity-tooltip');
|
|
2116
|
+
if (tip) tip.style.display = 'none';
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
} catch {
|
|
2121
|
+
if (section) section.style.display = 'none';
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
1866
2125
|
// ---------------------------------------------------------------------------
|
|
1867
2126
|
// Init
|
|
1868
2127
|
// ---------------------------------------------------------------------------
|
|
@@ -1878,7 +2137,8 @@ async function init() {
|
|
|
1878
2137
|
}
|
|
1879
2138
|
renderAll();
|
|
1880
2139
|
loadPortal();
|
|
1881
|
-
|
|
2140
|
+
startSherpaQuotes();
|
|
2141
|
+
loadActivityTrail();
|
|
1882
2142
|
connectWs();
|
|
1883
2143
|
|
|
1884
2144
|
// Show onboarding for first-time users
|
package/dashboard/index.html
CHANGED
|
@@ -17,6 +17,44 @@
|
|
|
17
17
|
|
|
18
18
|
<!-- Portal Home -->
|
|
19
19
|
<div id="portal">
|
|
20
|
+
<!-- 베이스캠프 씬 -->
|
|
21
|
+
<div class="portal-section" id="basecamp-scene">
|
|
22
|
+
<div class="bc-scene">
|
|
23
|
+
<!-- Stars -->
|
|
24
|
+
<div class="bc-star twinkle" style="top:8%;left:12%"></div>
|
|
25
|
+
<div class="bc-star twinkle-slow" style="top:15%;left:30%"></div>
|
|
26
|
+
<div class="bc-star twinkle" style="top:5%;left:48%"></div>
|
|
27
|
+
<div class="bc-star twinkle-slow" style="top:20%;left:65%"></div>
|
|
28
|
+
<div class="bc-star twinkle" style="top:10%;left:80%"></div>
|
|
29
|
+
<div class="bc-star twinkle-slow" style="top:25%;left:92%"></div>
|
|
30
|
+
<div class="bc-star twinkle" style="top:18%;left:5%"></div>
|
|
31
|
+
<div class="bc-star twinkle-slow" style="top:6%;left:72%"></div>
|
|
32
|
+
<div class="bc-star twinkle" style="top:22%;left:42%"></div>
|
|
33
|
+
<div class="bc-star twinkle-slow" style="top:12%;left:55%"></div>
|
|
34
|
+
|
|
35
|
+
<!-- Mountains -->
|
|
36
|
+
<div class="bc-mountain bc-mountain-back"></div>
|
|
37
|
+
<div class="bc-mountain bc-mountain-front"></div>
|
|
38
|
+
|
|
39
|
+
<!-- Snow caps -->
|
|
40
|
+
<div class="bc-snow" style="bottom:64px;left:48%;width:12px"></div>
|
|
41
|
+
<div class="bc-snow" style="bottom:60px;left:50%;width:8px"></div>
|
|
42
|
+
<div class="bc-snow" style="bottom:56px;left:46%;width:4px"></div>
|
|
43
|
+
|
|
44
|
+
<!-- Campfire glow + campfire -->
|
|
45
|
+
<div class="bc-glow"></div>
|
|
46
|
+
<div class="bc-campfire"></div>
|
|
47
|
+
|
|
48
|
+
<!-- Sherpa -->
|
|
49
|
+
<div class="bc-sherpa"></div>
|
|
50
|
+
|
|
51
|
+
<!-- Speech bubble -->
|
|
52
|
+
<div class="bc-speech fade-in" id="sherpa-speech">
|
|
53
|
+
<span id="sherpa-quote">요구사항 또 바뀌었댜... 뭐 그러려니 하쥬</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
20
58
|
<!-- 새로 시작 (핵심 액션, 맨 위) -->
|
|
21
59
|
<div class="portal-section">
|
|
22
60
|
<div class="portal-quickstart">
|
|
@@ -34,17 +72,16 @@
|
|
|
34
72
|
<div id="portal-work" class="portal-work-list"></div>
|
|
35
73
|
</div>
|
|
36
74
|
|
|
37
|
-
<!-- 추천 작업 (최대 5개, 이어하기와 중복 제거) -->
|
|
38
|
-
<div class="portal-section" id="portal-suggestions-section" style="display:none">
|
|
39
|
-
<h2 class="portal-section-title">이런 걸 해볼 수 있어요</h2>
|
|
40
|
-
<div id="portal-suggestions" class="portal-work-list"></div>
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
75
|
<!-- 기존 캠프가 있으면 아래에 카드 그리드 표시 -->
|
|
44
76
|
<div id="portal-camps-section" class="portal-section hidden">
|
|
45
77
|
<h2 class="portal-section-title">캠프</h2>
|
|
46
78
|
<div class="grid" id="grid"></div>
|
|
47
79
|
</div>
|
|
80
|
+
|
|
81
|
+
<!-- 활동 지형도 -->
|
|
82
|
+
<div class="portal-section" id="activity-trail-section" style="display:none">
|
|
83
|
+
<div id="activity-trail"></div>
|
|
84
|
+
</div>
|
|
48
85
|
</div>
|
|
49
86
|
|
|
50
87
|
<!-- Workspace View (full-screen preview + slide panel) -->
|
package/dashboard/style.css
CHANGED
|
@@ -2110,3 +2110,309 @@ header.hidden {
|
|
|
2110
2110
|
color: var(--text-muted, #666);
|
|
2111
2111
|
flex: 1;
|
|
2112
2112
|
}
|
|
2113
|
+
|
|
2114
|
+
/* ============================================================
|
|
2115
|
+
Basecamp Scene
|
|
2116
|
+
============================================================ */
|
|
2117
|
+
|
|
2118
|
+
.bc-scene {
|
|
2119
|
+
position: relative;
|
|
2120
|
+
height: 200px;
|
|
2121
|
+
background: linear-gradient(180deg, #080a10 0%, #0c0e14 60%, #12151e 100%);
|
|
2122
|
+
border-radius: 12px;
|
|
2123
|
+
overflow: hidden;
|
|
2124
|
+
border: 1px solid #1c2030;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/* Stars */
|
|
2128
|
+
.bc-star {
|
|
2129
|
+
position: absolute;
|
|
2130
|
+
width: 4px;
|
|
2131
|
+
height: 4px;
|
|
2132
|
+
background: #fff;
|
|
2133
|
+
image-rendering: pixelated;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
.bc-star.twinkle {
|
|
2137
|
+
animation: twinkle 2s steps(2) infinite;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
.bc-star.twinkle-slow {
|
|
2141
|
+
animation: twinkle 3.5s steps(2) infinite 0.8s;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
@keyframes twinkle {
|
|
2145
|
+
0%, 100% { opacity: 0.6; }
|
|
2146
|
+
50% { opacity: 0.1; }
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/* Mountains */
|
|
2150
|
+
.bc-mountain {
|
|
2151
|
+
position: absolute;
|
|
2152
|
+
bottom: 0;
|
|
2153
|
+
left: 0;
|
|
2154
|
+
right: 0;
|
|
2155
|
+
image-rendering: pixelated;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
.bc-mountain-back {
|
|
2159
|
+
height: 100px;
|
|
2160
|
+
background: #1a1d2a;
|
|
2161
|
+
clip-path: polygon(
|
|
2162
|
+
0% 100%, 0% 70%, 5% 70%, 5% 60%, 10% 60%, 10% 50%, 15% 50%, 15% 40%,
|
|
2163
|
+
20% 40%, 20% 35%, 25% 35%, 25% 45%, 30% 45%, 30% 55%, 35% 55%, 35% 50%,
|
|
2164
|
+
40% 50%, 40% 35%, 45% 35%, 45% 25%, 50% 25%, 50% 20%, 55% 20%, 55% 30%,
|
|
2165
|
+
60% 30%, 60% 40%, 65% 40%, 65% 50%, 70% 50%, 70% 40%, 75% 40%, 75% 50%,
|
|
2166
|
+
80% 50%, 80% 60%, 85% 60%, 85% 70%, 90% 70%, 90% 80%, 95% 80%, 100% 80%,
|
|
2167
|
+
100% 100%
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
.bc-mountain-front {
|
|
2172
|
+
height: 80px;
|
|
2173
|
+
background: #12151e;
|
|
2174
|
+
clip-path: polygon(
|
|
2175
|
+
0% 100%, 0% 80%, 5% 80%, 5% 70%, 10% 70%, 10% 55%, 15% 55%, 15% 45%,
|
|
2176
|
+
20% 45%, 20% 40%, 25% 40%, 25% 50%, 30% 50%, 30% 60%, 35% 60%, 35% 55%,
|
|
2177
|
+
40% 55%, 40% 40%, 45% 40%, 45% 30%, 48% 30%, 48% 25%, 52% 25%, 52% 20%,
|
|
2178
|
+
55% 20%, 55% 30%, 58% 30%, 58% 40%, 62% 40%, 62% 50%, 65% 50%, 65% 45%,
|
|
2179
|
+
70% 45%, 70% 35%, 75% 35%, 75% 45%, 80% 45%, 80% 55%, 85% 55%, 85% 65%,
|
|
2180
|
+
90% 65%, 90% 75%, 95% 75%, 95% 85%, 100% 85%, 100% 100%
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/* Snow caps */
|
|
2185
|
+
.bc-snow {
|
|
2186
|
+
position: absolute;
|
|
2187
|
+
height: 4px;
|
|
2188
|
+
background: rgba(255, 255, 255, 0.25);
|
|
2189
|
+
image-rendering: pixelated;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/* Campfire glow */
|
|
2193
|
+
.bc-glow {
|
|
2194
|
+
position: absolute;
|
|
2195
|
+
bottom: 0;
|
|
2196
|
+
left: 50%;
|
|
2197
|
+
transform: translateX(-50%);
|
|
2198
|
+
width: 80px;
|
|
2199
|
+
height: 40px;
|
|
2200
|
+
background: radial-gradient(ellipse at center bottom, rgba(255, 140, 50, 0.15) 0%, transparent 70%);
|
|
2201
|
+
animation: glow-pulse 2s ease infinite;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
@keyframes glow-pulse {
|
|
2205
|
+
0%, 100% { opacity: 1; }
|
|
2206
|
+
50% { opacity: 0.6; }
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/* Campfire pixel */
|
|
2210
|
+
.bc-campfire {
|
|
2211
|
+
position: absolute;
|
|
2212
|
+
bottom: 12px;
|
|
2213
|
+
left: 50%;
|
|
2214
|
+
transform: translateX(-50%);
|
|
2215
|
+
width: 4px;
|
|
2216
|
+
height: 4px;
|
|
2217
|
+
background: #8b4513;
|
|
2218
|
+
image-rendering: pixelated;
|
|
2219
|
+
box-shadow:
|
|
2220
|
+
-4px 0 0 #8b4513,
|
|
2221
|
+
4px 0 0 #8b4513,
|
|
2222
|
+
0 -4px 0 #ff6600,
|
|
2223
|
+
-4px -4px 0 #ff8800,
|
|
2224
|
+
4px -4px 0 #ff8800,
|
|
2225
|
+
0 -8px 0 #ffaa00,
|
|
2226
|
+
-4px -8px 0 #ff6600,
|
|
2227
|
+
4px -8px 0 #ff6600,
|
|
2228
|
+
0 -12px 0 #ffcc00;
|
|
2229
|
+
animation: bc-fire 0.4s steps(1) infinite;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
@keyframes bc-fire {
|
|
2233
|
+
0% {
|
|
2234
|
+
box-shadow:
|
|
2235
|
+
-4px 0 0 #8b4513,
|
|
2236
|
+
4px 0 0 #8b4513,
|
|
2237
|
+
0 -4px 0 #ff6600,
|
|
2238
|
+
-4px -4px 0 #ff8800,
|
|
2239
|
+
4px -4px 0 #ff8800,
|
|
2240
|
+
0 -8px 0 #ffaa00,
|
|
2241
|
+
-4px -8px 0 #ff6600,
|
|
2242
|
+
4px -8px 0 #ff6600,
|
|
2243
|
+
0 -12px 0 #ffcc00;
|
|
2244
|
+
}
|
|
2245
|
+
50% {
|
|
2246
|
+
box-shadow:
|
|
2247
|
+
-4px 0 0 #8b4513,
|
|
2248
|
+
4px 0 0 #8b4513,
|
|
2249
|
+
0 -4px 0 #ff8800,
|
|
2250
|
+
-4px -4px 0 #ff6600,
|
|
2251
|
+
4px -4px 0 #ffaa00,
|
|
2252
|
+
0 -8px 0 #ff6600,
|
|
2253
|
+
-4px -8px 0 #ffcc00,
|
|
2254
|
+
4px -8px 0 #ff8800,
|
|
2255
|
+
0 -12px 0 #ff6600;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/* Sherpa pixel character (idle pose) */
|
|
2260
|
+
.bc-sherpa {
|
|
2261
|
+
position: absolute;
|
|
2262
|
+
bottom: 16px;
|
|
2263
|
+
left: calc(50% + 24px);
|
|
2264
|
+
width: 4px;
|
|
2265
|
+
height: 4px;
|
|
2266
|
+
background: transparent;
|
|
2267
|
+
image-rendering: pixelated;
|
|
2268
|
+
box-shadow:
|
|
2269
|
+
/* Hat */
|
|
2270
|
+
0 -20px 0 #e74c3c,
|
|
2271
|
+
-4px -20px 0 #e74c3c,
|
|
2272
|
+
4px -20px 0 #e74c3c,
|
|
2273
|
+
-4px -24px 0 #e74c3c,
|
|
2274
|
+
0 -24px 0 #e74c3c,
|
|
2275
|
+
4px -24px 0 #e74c3c,
|
|
2276
|
+
/* Face */
|
|
2277
|
+
-4px -16px 0 #f5c6a0,
|
|
2278
|
+
0 -16px 0 #f5c6a0,
|
|
2279
|
+
4px -16px 0 #f5c6a0,
|
|
2280
|
+
/* Body */
|
|
2281
|
+
-4px -12px 0 #3498db,
|
|
2282
|
+
0 -12px 0 #3498db,
|
|
2283
|
+
4px -12px 0 #3498db,
|
|
2284
|
+
-4px -8px 0 #3498db,
|
|
2285
|
+
0 -8px 0 #3498db,
|
|
2286
|
+
4px -8px 0 #3498db,
|
|
2287
|
+
/* Backpack */
|
|
2288
|
+
8px -12px 0 #8b6914,
|
|
2289
|
+
8px -8px 0 #8b6914,
|
|
2290
|
+
/* Legs */
|
|
2291
|
+
-4px -4px 0 #2c3e50,
|
|
2292
|
+
0 -4px 0 #2c3e50,
|
|
2293
|
+
-4px 0 0 #2c3e50,
|
|
2294
|
+
4px 0 0 #2c3e50;
|
|
2295
|
+
animation: sherpa-idle 1s steps(1) infinite;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
@keyframes sherpa-idle {
|
|
2299
|
+
0%, 100% {
|
|
2300
|
+
box-shadow:
|
|
2301
|
+
0 -20px 0 #e74c3c,
|
|
2302
|
+
-4px -20px 0 #e74c3c,
|
|
2303
|
+
4px -20px 0 #e74c3c,
|
|
2304
|
+
-4px -24px 0 #e74c3c,
|
|
2305
|
+
0 -24px 0 #e74c3c,
|
|
2306
|
+
4px -24px 0 #e74c3c,
|
|
2307
|
+
-4px -16px 0 #f5c6a0,
|
|
2308
|
+
0 -16px 0 #f5c6a0,
|
|
2309
|
+
4px -16px 0 #f5c6a0,
|
|
2310
|
+
-4px -12px 0 #3498db,
|
|
2311
|
+
0 -12px 0 #3498db,
|
|
2312
|
+
4px -12px 0 #3498db,
|
|
2313
|
+
-4px -8px 0 #3498db,
|
|
2314
|
+
0 -8px 0 #3498db,
|
|
2315
|
+
4px -8px 0 #3498db,
|
|
2316
|
+
8px -12px 0 #8b6914,
|
|
2317
|
+
8px -8px 0 #8b6914,
|
|
2318
|
+
-4px -4px 0 #2c3e50,
|
|
2319
|
+
0 -4px 0 #2c3e50,
|
|
2320
|
+
-4px 0 0 #2c3e50,
|
|
2321
|
+
4px 0 0 #2c3e50;
|
|
2322
|
+
}
|
|
2323
|
+
50% {
|
|
2324
|
+
box-shadow:
|
|
2325
|
+
0 -20px 0 #e74c3c,
|
|
2326
|
+
-4px -20px 0 #e74c3c,
|
|
2327
|
+
4px -20px 0 #e74c3c,
|
|
2328
|
+
-4px -24px 0 #e74c3c,
|
|
2329
|
+
0 -24px 0 #e74c3c,
|
|
2330
|
+
4px -24px 0 #e74c3c,
|
|
2331
|
+
-4px -16px 0 #f5c6a0,
|
|
2332
|
+
0 -16px 0 #f5c6a0,
|
|
2333
|
+
4px -16px 0 #f5c6a0,
|
|
2334
|
+
-4px -12px 0 #3498db,
|
|
2335
|
+
0 -12px 0 #3498db,
|
|
2336
|
+
4px -12px 0 #3498db,
|
|
2337
|
+
-4px -8px 0 #3498db,
|
|
2338
|
+
0 -8px 0 #3498db,
|
|
2339
|
+
4px -8px 0 #3498db,
|
|
2340
|
+
8px -12px 0 #8b6914,
|
|
2341
|
+
8px -8px 0 #8b6914,
|
|
2342
|
+
-4px -4px 0 #2c3e50,
|
|
2343
|
+
4px -4px 0 #2c3e50,
|
|
2344
|
+
-4px 0 0 #2c3e50,
|
|
2345
|
+
0 0 0 #2c3e50;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/* Speech bubble */
|
|
2350
|
+
.bc-speech {
|
|
2351
|
+
position: absolute;
|
|
2352
|
+
bottom: 52px;
|
|
2353
|
+
left: calc(50% + 8px);
|
|
2354
|
+
background: #1c2030;
|
|
2355
|
+
border: 1px solid #2a2f42;
|
|
2356
|
+
border-radius: 8px;
|
|
2357
|
+
padding: 6px 10px;
|
|
2358
|
+
font-size: 11px;
|
|
2359
|
+
white-space: nowrap;
|
|
2360
|
+
color: var(--text-secondary);
|
|
2361
|
+
animation: bubble-float 3s ease-in-out infinite;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
.bc-speech::after {
|
|
2365
|
+
content: '';
|
|
2366
|
+
position: absolute;
|
|
2367
|
+
bottom: -6px;
|
|
2368
|
+
left: 20px;
|
|
2369
|
+
width: 0;
|
|
2370
|
+
height: 0;
|
|
2371
|
+
border-left: 6px solid transparent;
|
|
2372
|
+
border-right: 6px solid transparent;
|
|
2373
|
+
border-top: 6px solid #1c2030;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
@keyframes bubble-float {
|
|
2377
|
+
0%, 100% { transform: translateY(0); }
|
|
2378
|
+
50% { transform: translateY(-3px); }
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
.bc-speech.fade-out {
|
|
2382
|
+
opacity: 0;
|
|
2383
|
+
transition: opacity 0.5s;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
.bc-speech.fade-in {
|
|
2387
|
+
opacity: 1;
|
|
2388
|
+
transition: opacity 0.5s;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/* ============================================================
|
|
2392
|
+
Activity Trail
|
|
2393
|
+
============================================================ */
|
|
2394
|
+
|
|
2395
|
+
#activity-trail {
|
|
2396
|
+
background: #0a0c11;
|
|
2397
|
+
border: 1px solid #1c2030;
|
|
2398
|
+
border-radius: 10px;
|
|
2399
|
+
overflow: hidden;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
.activity-info {
|
|
2403
|
+
padding: 12px 20px;
|
|
2404
|
+
display: flex;
|
|
2405
|
+
justify-content: space-between;
|
|
2406
|
+
align-items: center;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
.activity-streak {
|
|
2410
|
+
font-size: 13px;
|
|
2411
|
+
color: #8fbc8f;
|
|
2412
|
+
font-weight: 600;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
.activity-period {
|
|
2416
|
+
font-size: 11px;
|
|
2417
|
+
color: var(--text-muted);
|
|
2418
|
+
}
|
package/dist/lib/server.js
CHANGED
|
@@ -304,10 +304,13 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
304
304
|
if (!Number.isFinite(targetPort) || targetPort < 1000 || targetPort > 65535) {
|
|
305
305
|
return res.status(400).send("Invalid port");
|
|
306
306
|
}
|
|
307
|
-
//
|
|
307
|
+
// Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
|
|
308
308
|
const camps = getAll();
|
|
309
309
|
const camp = camps.find(c => c.fePort === targetPort);
|
|
310
|
-
|
|
310
|
+
if (!camp) {
|
|
311
|
+
return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
|
|
312
|
+
}
|
|
313
|
+
const campName = camp.name;
|
|
311
314
|
const targetPath = req.url || "/";
|
|
312
315
|
const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
|
|
313
316
|
const contentType = proxyRes.headers["content-type"] || "";
|
|
@@ -1357,6 +1360,70 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1357
1360
|
}
|
|
1358
1361
|
res.json({ fixed: false, description: fix?.description ?? "자동으로 고칠 수 있는 문제를 찾지 못했습니다." });
|
|
1359
1362
|
});
|
|
1363
|
+
let activityCache = null;
|
|
1364
|
+
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
1365
|
+
app.get("/api/activity", (_req, res) => {
|
|
1366
|
+
if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
|
|
1367
|
+
return res.json(activityCache.data);
|
|
1368
|
+
}
|
|
1369
|
+
// --- daily commits (last 4 weeks) ---
|
|
1370
|
+
const gitLog = spawnSync("git", ["log", "--since=4 weeks ago", "--format=%ad", "--date=short"], {
|
|
1371
|
+
cwd: projectRoot,
|
|
1372
|
+
stdio: "pipe",
|
|
1373
|
+
encoding: "utf8",
|
|
1374
|
+
});
|
|
1375
|
+
const commitDates = (gitLog.status === 0 ? gitLog.stdout : "").trim().split("\n").filter(Boolean);
|
|
1376
|
+
const countByDate = new Map();
|
|
1377
|
+
for (const d of commitDates) {
|
|
1378
|
+
countByDate.set(d, (countByDate.get(d) ?? 0) + 1);
|
|
1379
|
+
}
|
|
1380
|
+
// Fill missing days in the 4-week window
|
|
1381
|
+
const daily = [];
|
|
1382
|
+
const now = new Date();
|
|
1383
|
+
for (let i = 27; i >= 0; i--) {
|
|
1384
|
+
const d = new Date(now);
|
|
1385
|
+
d.setDate(d.getDate() - i);
|
|
1386
|
+
const key = d.toISOString().slice(0, 10);
|
|
1387
|
+
daily.push({ date: key, commits: countByDate.get(key) ?? 0 });
|
|
1388
|
+
}
|
|
1389
|
+
// --- merged PRs ---
|
|
1390
|
+
let mergedPrs = [];
|
|
1391
|
+
const ghResult = spawnSync("gh", ["pr", "list", "--state", "merged", "--limit", "10", "--json", "number,title,mergedAt"], {
|
|
1392
|
+
cwd: projectRoot,
|
|
1393
|
+
stdio: "pipe",
|
|
1394
|
+
encoding: "utf8",
|
|
1395
|
+
});
|
|
1396
|
+
if (ghResult.status === 0 && ghResult.stdout) {
|
|
1397
|
+
try {
|
|
1398
|
+
const parsed = JSON.parse(ghResult.stdout);
|
|
1399
|
+
mergedPrs = parsed;
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
// malformed JSON — keep empty
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// --- streak (consecutive days with commits, backward from today) ---
|
|
1406
|
+
let streak = 0;
|
|
1407
|
+
const todayStr = now.toISOString().slice(0, 10);
|
|
1408
|
+
for (let i = 0; i <= 27; i++) {
|
|
1409
|
+
const d = new Date(now);
|
|
1410
|
+
d.setDate(d.getDate() - i);
|
|
1411
|
+
const key = d.toISOString().slice(0, 10);
|
|
1412
|
+
if ((countByDate.get(key) ?? 0) > 0) {
|
|
1413
|
+
streak++;
|
|
1414
|
+
}
|
|
1415
|
+
else if (key === todayStr) {
|
|
1416
|
+
// Today having 0 commits is ok — streak can start from yesterday
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const data = { daily, mergedPrs, streak };
|
|
1424
|
+
activityCache = { data, ts: Date.now() };
|
|
1425
|
+
res.json(data);
|
|
1426
|
+
});
|
|
1360
1427
|
// SPA fallback
|
|
1361
1428
|
app.get("*", (_req, res) => {
|
|
1362
1429
|
res.sendFile(join(dashboardDir, "index.html"));
|