feat: Add GlowTrack mood and habit wellbeing grid specifications

- Introduced export schema for JSON data structure.
- Created renderer contract detailing canvas/SVG rendering requirements.
- Defined IndexedDB storage schema and migration strategies.
- Documented data model including entities and relationships.
- Developed implementation plan outlining execution flow and project structure.
- Provided quickstart guide for development environment setup.
- Compiled research documentation on performance, accessibility, and theming.
- Established feature specification with user scenarios and functional requirements.
This commit is contained in:
2025-09-18 00:36:13 -06:00
parent 080742a25b
commit 2f096d0265
19 changed files with 1112 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
# Storage Import & Migration Benchmark
Benchmarks JSON import and basic IndexedDB migrations for GlowTrack.
## Use
- Open `import-benchmark.html` in a browser.
- Click "Generate 3-year JSON" then "Import".
- Observe timings for days vs entries transactions.
## Goals
- Determine chunking and transaction strategies.
- Inform UX for progress and error handling.

View File

@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GlowTrack IndexedDB Import Benchmark</title>
<style> body { font-family: system-ui, sans-serif; padding: 1rem; } textarea { width: 100%; height: 120px; } </style>
</head>
<body>
<h1>IndexedDB Import Benchmark</h1>
<button id="seed">Generate 3-year JSON</button>
<label>Chunk size: <input id="chunk" type="number" min="10" max="5000" value="500" /></label>
<button id="import">Import</button>
<button id="runMatrix">Run Matrix</button>
<button id="download">Download JSON Report</button>
<button id="wipe">Wipe DB</button>
<pre id="log"></pre>
<script>
const log = (...args) => (document.getElementById('log').textContent += args.join(' ') + '\n');
function openDB(version = 1) {
return new Promise((resolve, reject) => {
const req = indexedDB.open('glowtrack', version);
req.onupgradeneeded = (e) => {
const db = req.result;
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings');
if (!db.objectStoreNames.contains('habits')) db.createObjectStore('habits', { keyPath: 'id' });
if (!db.objectStoreNames.contains('days')) db.createObjectStore('days', { keyPath: 'date' });
if (!db.objectStoreNames.contains('entries')) {
const s = db.createObjectStore('entries', { keyPath: 'id' });
s.createIndex('by_date', 'date');
s.createIndex('by_habit', 'habitId');
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function randomUUID(){return crypto.randomUUID?crypto.randomUUID():('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,function(c){var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16)}))}
function generateData(years=3){
const today = new Date();
const start = new Date(today);
start.setDate(start.getDate() - years*365);
const days = [];
const habits = [
{ id: 'h1', type: 'positive', label: 'Walk', defaultWeight: 1, archived: false },
{ id: 'h2', type: 'negative', label: 'Late snack', defaultWeight: 1, archived: false }
];
for(let d=new Date(start); d<=today; d.setDate(d.getDate()+1)){
const date = d.toISOString().slice(0,10);
const entries = [];
if (Math.random() < 0.6) entries.push({ id: randomUUID(), type:'positive', habitId:'h1', label:'Walk', weight:1, timestamp:new Date(d).toISOString(), date });
if (Math.random() < 0.3) entries.push({ id: randomUUID(), type:'negative', habitId:'h2', label:'Late snack', weight:1, timestamp:new Date(d).toISOString(), date });
const hue = Math.floor(Math.random()*360);
const intensity = Math.random();
days.push({ date, mood:{hue,intensity}, entries });
}
return { version: '1.0.0', app:{name:'GlowTrack', version:'0.0.0'}, exportedAt: new Date().toISOString(), data:{ settings:{}, habits, days } };
}
async function importJSON(db, json, chunk=Infinity){
const tx1 = db.transaction(['days'], 'readwrite');
const daysStore = tx1.objectStore('days');
const t0 = performance.now();
let i=0; const days = json.data.days;
while(i < days.length){
const slice = days.slice(i, Math.min(i+chunk, days.length));
for(const day of slice){ await new Promise((res, rej)=>{ const r = daysStore.put({date:day.date, mood:day.mood}); r.onsuccess=()=>res(); r.onerror=()=>rej(r.error); }); }
i += slice.length;
}
await new Promise((res, rej)=>{ tx1.oncomplete=()=>res(); tx1.onerror=()=>rej(tx1.error); });
const t1 = performance.now();
const tx2 = db.transaction(['entries'], 'readwrite');
const entriesStore = tx2.objectStore('entries');
i=0; const entries = json.data.days.flatMap(d=>d.entries);
while(i < entries.length){
const slice = entries.slice(i, Math.min(i+chunk, entries.length));
for(const e of slice){ await new Promise((res, rej)=>{ const r = entriesStore.put(e); r.onsuccess=()=>res(); r.onerror=()=>rej(r.error); }); }
i += slice.length;
}
await new Promise((res, rej)=>{ tx2.oncomplete=()=>res(); tx2.onerror=()=>rej(tx2.error); });
const t2 = performance.now();
return { daysMs: t1 - t0, entriesMs: t2 - t1 };
}
document.getElementById('seed').onclick = () => {
window.generated = generateData(3);
log('Generated days:', window.generated.data.days.length);
};
const report = { runs: [] };
document.getElementById('import').onclick = async () => {
if (!window.generated) { log('Generate first.'); return; }
const db = await openDB(1);
const chunk = +document.getElementById('chunk').value || Infinity;
const res = await importJSON(db, window.generated, chunk);
log('Imported. Chunk:', chunk, 'Days(ms):', res.daysMs.toFixed(0), 'Entries(ms):', res.entriesMs.toFixed(0));
report.runs.push({ date: new Date().toISOString(), chunk, ...res });
db.close();
};
document.getElementById('runMatrix').onclick = async () => {
if (!window.generated) { log('Generate first.'); return; }
const chunks = [100, 250, 500, 1000, 2000, 5000];
for(const ch of chunks){
await new Promise((r)=>{ const del = indexedDB.deleteDatabase('glowtrack'); del.onsuccess=r; del.onerror=r; });
const db2 = await openDB(1);
const res = await importJSON(db2, window.generated, ch);
report.runs.push({ date: new Date().toISOString(), chunk: ch, ...res });
log('Matrix:', ch, 'Days(ms):', res.daysMs.toFixed(0), 'Entries(ms):', res.entriesMs.toFixed(0));
db2.close();
}
alert('Matrix complete');
};
document.getElementById('download').onclick = () => {
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'storage-benchmark.json'; a.click();
URL.revokeObjectURL(url);
};
document.getElementById('wipe').onclick = async () => {
await new Promise((res, rej)=>{ const r = indexedDB.deleteDatabase('glowtrack'); r.onsuccess=res; r.onerror=()=>rej(r.error); });
log('DB wiped');
};
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
# Viz FPS Harness
A simple Canvas benchmark to evaluate tile rendering performance (FPS), DPR scaling, and animation cost for GlowTrack.
## Use
- Open `index.html` in a browser (mobile preferred).
- Adjust:
- Tiles: 30365
- Animate: on/off
- Device Pixel Ratio: 13
- Observe FPS and rendering smoothness.
## Goals
- Validate 60 fps target for typical grids.
- Identify breakpoints for fallbacks (reduced motion, static render).

157
packages/viz/poc/index.html Normal file
View File

@@ -0,0 +1,157 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GlowTrack Viz FPS Harness</title>
<style>
html, body { height: 100%; margin: 0; font-family: system-ui, sans-serif; }
#controls { padding: 8px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
#fps { font-weight: bold; }
canvas { display: block; width: 100%; height: calc(100% - 48px); background: #0b0b10; }
</style>
</head>
<body>
<div id="controls">
<label>Tiles: <input id="tiles" type="number" min="30" max="365" value="365" /></label>
<label>Animate: <input id="animate" type="checkbox" checked /></label>
<label>Device Pixel Ratio: <input id="dpr" type="number" min="1" max="3" step="0.25" value="1" /></label>
<button id="runBench">Run Bench (30,90,180,365)</button>
<button id="download">Download JSON Report</button>
<span id="fps">FPS: --</span>
</div>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const tilesInput = document.getElementById('tiles');
const animateInput = document.getElementById('animate');
const dprInput = document.getElementById('dpr');
const fpsEl = document.getElementById('fps');
let tiles = +tilesInput.value;
let animate = animateInput.checked;
let dpr = +dprInput.value;
const state = [];
function seed() {
state.length = 0;
const today = new Date();
for (let i = 0; i < tiles; i++) {
const hue = (i * 11) % 360;
const net = Math.sin(i) * 2; // -2..2
state.push({ hue, net, pos: (i % 5), neg: (i % 3) });
}
}
function resize() {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(time) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const padding = 8, gap = 2;
const cols = Math.ceil(Math.sqrt(tiles));
const rows = Math.ceil(tiles / cols);
const tileSize = Math.min((canvas.width/dpr - padding*2 - gap*(cols-1)) / cols,
(canvas.height/dpr - padding*2 - gap*(rows-1)) / rows);
let x = padding, y = padding;
for (let i = 0; i < tiles; i++) {
const s = state[i];
const glow = Math.max(0, Math.min(1, (s.net + (animate ? 0.5*Math.sin(time/500 + i) : 0)) / 3));
const lum = 20 + glow * 65; // luminance range 20..85
ctx.fillStyle = `hsl(${s.hue}deg 80% ${lum}%)`;
ctx.fillRect(x, y, tileSize, tileSize);
// static overlay for negative
if (s.neg > 0) {
ctx.globalAlpha = 0.08 + 0.03 * s.neg;
for (let n = 0; n < 10; n++) {
const rx = x + Math.random() * tileSize;
const ry = y + Math.random() * tileSize;
ctx.fillStyle = '#000000';
ctx.fillRect(rx, ry, 1, 1);
}
ctx.globalAlpha = 1;
}
// glyphs
ctx.fillStyle = '#fff';
for (let p = 0; p < s.pos; p++) {
ctx.fillRect(x + 2 + p*3, y + tileSize - 4, 2, 2);
}
ctx.fillStyle = '#bbb';
for (let n = 0; n < s.neg; n++) {
ctx.fillRect(x + tileSize - 2 - n*3, y + tileSize - 4, 2, 2);
}
x += tileSize + gap;
if ((i+1) % cols === 0) { x = padding; y += tileSize + gap; }
}
}
let last = performance.now();
let frames = 0;
const report = { runs: [] };
function loop(ts) {
draw(ts);
frames++;
if (ts - last > 1000) {
fpsEl.textContent = `FPS: ${frames}`;
frames = 0; last = ts;
}
requestAnimationFrame(loop);
}
tilesInput.oninput = () => { tiles = +tilesInput.value; seed(); };
animateInput.onchange = () => { animate = animateInput.checked; };
dprInput.oninput = () => { dpr = +dprInput.value; resize(); };
window.addEventListener('resize', resize);
seed(); resize(); requestAnimationFrame(loop);
async function runOnce(tCount){
tiles = tCount; tilesInput.value = tCount; seed();
return new Promise((resolve) => {
let f = 0; const tStart = performance.now();
const orig = requestAnimationFrame;
function sample(ts){
f++;
if (ts - tStart >= 1500) {
resolve({ tiles: tCount, fps: Math.round((f*1000)/(ts - tStart)) });
} else { orig(sample); }
}
orig(sample);
});
}
document.getElementById('runBench').onclick = async () => {
const dprPrev = dpr; const animPrev = animate;
const dprs = [1, 2];
const counts = [30, 90, 180, 365];
const results = [];
for (const d of dprs) {
dpr = d; dprInput.value = d; resize();
for (const a of [false, true]) {
animate = a; animateInput.checked = a;
for (const c of counts) {
const r = await runOnce(c);
results.push({ dpr: d, animate: a, ...r });
}
}
}
dpr = dprPrev; dprInput.value = dprPrev; animate = animPrev; animateInput.checked = animPrev; resize();
report.runs.push({ date: new Date().toISOString(), results });
alert('Benchmark complete');
};
document.getElementById('download').onclick = () => {
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'viz-benchmark.json'; a.click();
URL.revokeObjectURL(url);
};
</script>
</body>
</html>