T019
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"test:ui": "vitest"
|
"test:ui": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1"
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
83
packages/storage/src/compute.ts
Normal file
83
packages/storage/src/compute.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Compute helpers for GlowTrack storage (T019)
|
||||||
|
* - netScore: sum(positive weights) - sum(negative weights)
|
||||||
|
* - clamps: clamp01, clampRange
|
||||||
|
* - derived mapping: luminanceFromNetScore (gentle easing, 0..1)
|
||||||
|
* - entry CRUD helpers that recompute DayTile.netScore
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DayTile, HabitEntry, Mood } from './models';
|
||||||
|
|
||||||
|
export type HabitType = HabitEntry['type'];
|
||||||
|
|
||||||
|
export function signForType(type: HabitType): 1 | -1 {
|
||||||
|
return type === 'positive' ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute netScore for a set of entries: sum(sign(type) * weight)
|
||||||
|
*/
|
||||||
|
export function computeNetScore(entries: HabitEntry[] | undefined | null): number {
|
||||||
|
if (!entries || entries.length === 0) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
const w = Math.max(0, Number.isFinite(e.weight) ? e.weight : 0);
|
||||||
|
total += signForType(e.type) * w;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a value to [min, max] */
|
||||||
|
export function clampRange(x: number, min: number, max: number): number {
|
||||||
|
if (min > max) [min, max] = [max, min];
|
||||||
|
return Math.min(max, Math.max(min, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a value to [0,1] */
|
||||||
|
export function clamp01(x: number): number {
|
||||||
|
return clampRange(x, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map netScore (unbounded) to a luminance in [0,1] using a smooth S-curve.
|
||||||
|
* The curve centers at 0 -> 0.5 and eases toward 0/1 for large magnitudes.
|
||||||
|
* scale controls how quickly it saturates (higher = slower).
|
||||||
|
*/
|
||||||
|
export function luminanceFromNetScore(netScore: number, scale = 5): number {
|
||||||
|
const s = Math.max(1e-6, scale);
|
||||||
|
const t = Math.tanh(netScore / s); // -1..1
|
||||||
|
return clamp01(0.5 + 0.5 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute DayTile.netScore from its entries */
|
||||||
|
export function recomputeDayNetScore(day: DayTile): number {
|
||||||
|
return computeNetScore(day.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace entries on a day and recompute netScore (immutable) */
|
||||||
|
export function replaceEntriesForDay(day: DayTile, entries: HabitEntry[]): DayTile {
|
||||||
|
return { ...day, entries: entries.slice(), netScore: computeNetScore(entries) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an entry to a day (immutable) */
|
||||||
|
export function addEntryToDay(day: DayTile, entry: HabitEntry): DayTile {
|
||||||
|
const entries = [...day.entries, entry];
|
||||||
|
return { ...day, entries, netScore: computeNetScore(entries) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing entry by id (immutable; no-op if not found) */
|
||||||
|
export function updateEntryInDay(day: DayTile, entry: HabitEntry): DayTile {
|
||||||
|
const entries = day.entries.map((e) => (e.id === entry.id ? entry : e));
|
||||||
|
return { ...day, entries, netScore: computeNetScore(entries) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an entry by id (immutable; no-op if not found) */
|
||||||
|
export function removeEntryFromDay(day: DayTile, entryId: string): DayTile {
|
||||||
|
const entries = day.entries.filter((e) => e.id !== entryId);
|
||||||
|
return { ...day, entries, netScore: computeNetScore(entries) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update mood for a day (immutable) */
|
||||||
|
export function setMoodForDay(day: DayTile, mood: Mood): DayTile {
|
||||||
|
return { ...day, mood };
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import Ajv, { type ErrorObject } from 'ajv';
|
import Ajv, { type ErrorObject } from 'ajv';
|
||||||
|
import addFormats from 'ajv-formats';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
// Implementation placeholder import; will fail until implemented per tasks T016, T018
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-expect-error - module not implemented yet
|
|
||||||
import { exportToJson } from '../../src/export';
|
import { exportToJson } from '../../src/export';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -20,7 +18,8 @@ const schemaPath = path.join(
|
|||||||
describe('Contract: export JSON schema (T009)', () => {
|
describe('Contract: export JSON schema (T009)', () => {
|
||||||
it('exportToJson() output should validate against export.schema.json', async () => {
|
it('exportToJson() output should validate against export.schema.json', async () => {
|
||||||
const schemaJson = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
const schemaJson = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
||||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||||
|
addFormats(ajv);
|
||||||
const validate = ajv.compile(schemaJson);
|
const validate = ajv.compile(schemaJson);
|
||||||
|
|
||||||
// Minimal call; actual implementation will read from DB/models
|
// Minimal call; actual implementation will read from DB/models
|
||||||
|
|||||||
66
packages/storage/tests/unit/compute.spec.ts
Normal file
66
packages/storage/tests/unit/compute.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
computeNetScore,
|
||||||
|
clamp01,
|
||||||
|
clampRange,
|
||||||
|
luminanceFromNetScore,
|
||||||
|
addEntryToDay,
|
||||||
|
updateEntryInDay,
|
||||||
|
removeEntryFromDay,
|
||||||
|
replaceEntriesForDay,
|
||||||
|
recomputeDayNetScore
|
||||||
|
} from '../../src/compute';
|
||||||
|
|
||||||
|
import type { DayTile, HabitEntry } from '../../src/models';
|
||||||
|
|
||||||
|
function day(entries: HabitEntry[] = []): DayTile {
|
||||||
|
return {
|
||||||
|
date: '2025-01-01',
|
||||||
|
mood: { hue: 180, intensity: 0.5 },
|
||||||
|
entries,
|
||||||
|
netScore: computeNetScore(entries)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compute helpers', () => {
|
||||||
|
it('computes netScore correctly', () => {
|
||||||
|
const entries: HabitEntry[] = [
|
||||||
|
{ id: '1', type: 'positive', habitId: 'h1', label: 'A', weight: 1, timestamp: new Date().toISOString() },
|
||||||
|
{ id: '2', type: 'positive', habitId: 'h2', label: 'B', weight: 2, timestamp: new Date().toISOString() },
|
||||||
|
{ id: '3', type: 'negative', habitId: 'h3', label: 'C', weight: 1, timestamp: new Date().toISOString() }
|
||||||
|
];
|
||||||
|
expect(computeNetScore(entries)).toBe(1 + 2 - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps values', () => {
|
||||||
|
expect(clampRange(5, 0, 3)).toBe(3);
|
||||||
|
expect(clampRange(-2, 0, 3)).toBe(0);
|
||||||
|
expect(clamp01(2)).toBe(1);
|
||||||
|
expect(clamp01(-1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps luminance smoothly', () => {
|
||||||
|
const lo = luminanceFromNetScore(-100);
|
||||||
|
const hi = luminanceFromNetScore(100);
|
||||||
|
const mid = luminanceFromNetScore(0);
|
||||||
|
expect(lo).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(hi).toBeLessThanOrEqual(1);
|
||||||
|
expect(hi).toBeGreaterThan(lo);
|
||||||
|
expect(Math.abs(mid - 0.5)).toBeLessThan(1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes on CRUD operations', () => {
|
||||||
|
let d = day();
|
||||||
|
d = addEntryToDay(d, { id: 'e1', type: 'positive', habitId: 'h1', label: 'A', weight: 2, timestamp: new Date().toISOString() });
|
||||||
|
expect(d.netScore).toBe(2);
|
||||||
|
d = addEntryToDay(d, { id: 'e2', type: 'negative', habitId: 'h2', label: 'B', weight: 1, timestamp: new Date().toISOString() });
|
||||||
|
expect(d.netScore).toBe(1);
|
||||||
|
d = updateEntryInDay(d, { id: 'e2', type: 'negative', habitId: 'h2', label: 'B', weight: 2, timestamp: new Date().toISOString() });
|
||||||
|
expect(d.netScore).toBe(0);
|
||||||
|
d = removeEntryFromDay(d, 'e1');
|
||||||
|
expect(d.netScore).toBe(-2);
|
||||||
|
d = replaceEntriesForDay(d, []);
|
||||||
|
expect(d.netScore).toBe(0);
|
||||||
|
expect(recomputeDayNetScore(d)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -84,6 +84,9 @@ importers:
|
|||||||
ajv:
|
ajv:
|
||||||
specifier: ^8.17.1
|
specifier: ^8.17.1
|
||||||
version: 8.17.1
|
version: 8.17.1
|
||||||
|
ajv-formats:
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1(ajv@8.17.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
fake-indexeddb:
|
fake-indexeddb:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
@@ -614,6 +617,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1:
|
||||||
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
|
peerDependencies:
|
||||||
|
ajv: ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ajv:
|
||||||
|
optional: true
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
@@ -2324,6 +2335,10 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1(ajv@8.17.1):
|
||||||
|
optionalDependencies:
|
||||||
|
ajv: 8.17.1
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ From data-model.md → model creation tasks [P]
|
|||||||
- Ensure JSON conforms to export.schema.json (version, app, exportedAt, data)
|
- Ensure JSON conforms to export.schema.json (version, app, exportedAt, data)
|
||||||
- Dependencies: T016, T009, T017
|
- Dependencies: T016, T009, T017
|
||||||
|
|
||||||
- [ ] T019 [P] Compute helpers (netScore, derivations)
|
- [X] T019 [P] Compute helpers (netScore, derivations)
|
||||||
- Create /home/jawz/Development/Projects/GlowTrack/packages/storage/src/compute.ts implementing netScore, clamps, updates on entry CRUD
|
- Create /home/jawz/Development/Projects/GlowTrack/packages/storage/src/compute.ts implementing netScore, clamps, updates on entry CRUD
|
||||||
- Dependencies: T016
|
- Dependencies: T016
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user