From 8187a8f5ac5f9e46befe8691e0199ca3ba697116 Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Fri, 19 Sep 2025 00:40:19 -0600 Subject: [PATCH] T019 --- packages/storage/package.json | 3 +- packages/storage/src/compute.ts | 83 +++++++++++++++++++ .../storage/tests/contract/export.spec.ts | 7 +- packages/storage/tests/unit/compute.spec.ts | 66 +++++++++++++++ pnpm-lock.yaml | 15 ++++ specs/001-glowtrack-a-mood/tasks.md | 2 +- 6 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 packages/storage/src/compute.ts create mode 100644 packages/storage/tests/unit/compute.spec.ts diff --git a/packages/storage/package.json b/packages/storage/package.json index 758d26d..c535077 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -10,7 +10,8 @@ "test:ui": "vitest" }, "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" }, "devDependencies": { "typescript": "^5.5.4", diff --git a/packages/storage/src/compute.ts b/packages/storage/src/compute.ts new file mode 100644 index 0000000..4e03f56 --- /dev/null +++ b/packages/storage/src/compute.ts @@ -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 }; +} diff --git a/packages/storage/tests/contract/export.spec.ts b/packages/storage/tests/contract/export.spec.ts index 1d2d2df..6f68e32 100644 --- a/packages/storage/tests/contract/export.spec.ts +++ b/packages/storage/tests/contract/export.spec.ts @@ -1,12 +1,10 @@ import { describe, it, expect } from 'vitest'; import Ajv, { type ErrorObject } from 'ajv'; +import addFormats from 'ajv-formats'; import fs from 'node:fs'; import path from 'node:path'; 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'; const __filename = fileURLToPath(import.meta.url); @@ -20,7 +18,8 @@ const schemaPath = path.join( describe('Contract: export JSON schema (T009)', () => { it('exportToJson() output should validate against export.schema.json', async () => { 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); // Minimal call; actual implementation will read from DB/models diff --git a/packages/storage/tests/unit/compute.spec.ts b/packages/storage/tests/unit/compute.spec.ts new file mode 100644 index 0000000..63116b7 --- /dev/null +++ b/packages/storage/tests/unit/compute.spec.ts @@ -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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50a9f36..506af10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: ajv: specifier: ^8.17.1 version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) devDependencies: fake-indexeddb: specifier: ^6.0.0 @@ -614,6 +617,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} 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: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2324,6 +2335,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 diff --git a/specs/001-glowtrack-a-mood/tasks.md b/specs/001-glowtrack-a-mood/tasks.md index 083d17a..81b1759 100644 --- a/specs/001-glowtrack-a-mood/tasks.md +++ b/specs/001-glowtrack-a-mood/tasks.md @@ -126,7 +126,7 @@ From data-model.md → model creation tasks [P] - Ensure JSON conforms to export.schema.json (version, app, exportedAt, data) - 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 - Dependencies: T016