Skip to content

Commit c9841e0

Browse files
committed
v0.2.0: Interactive Batch Timeline — Visualize the entire cheesemaking process for a batch, showing pH, temperature, and activity logs chronologically.
1 parent b01cea1 commit c9841e0

4 files changed

Lines changed: 548 additions & 3 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@adametherzlab/cheese-log",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Cheesemaking batch logger with culture, pH, and aging tracking",
55
"type": "module",
66
"main": "src/index.ts",
@@ -44,4 +44,4 @@
4444
"node": ">=18"
4545
},
4646
"sideEffects": false
47-
}
47+
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./types.js";
22
export * from "./batch.js";
3-
export * from "./storage.js";
3+
export * from "./storage.js";
4+
export * from "./timeline.js";

src/timeline.ts

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import type { Batch, TemperatureLog, PHLog, RennetAddition, PressingStage, AgingEntry } from "./types.js";
2+
3+
/** A single event on the batch timeline */
4+
export interface TimelineEvent {
5+
readonly timestamp: number;
6+
readonly time: string;
7+
readonly type: "temperature" | "ph" | "rennet" | "pressing" | "aging" | "culture" | "start";
8+
readonly label: string;
9+
readonly value?: number;
10+
readonly unit?: string;
11+
readonly note?: string;
12+
}
13+
14+
/** Complete timeline data for a batch */
15+
export interface BatchTimeline {
16+
readonly batchId: string;
17+
readonly batchName: string;
18+
readonly startTime: string;
19+
readonly events: ReadonlyArray<TimelineEvent>;
20+
readonly temperatureSeries: ReadonlyArray<{ time: string; value: number }>;
21+
readonly phSeries: ReadonlyArray<{ time: string; value: number }>;
22+
readonly durationMinutes: number;
23+
}
24+
25+
/**
26+
* Build a chronological timeline of all events in a batch.
27+
* Merges pH, temperature, rennet, pressing, aging, and culture logs
28+
* into a single sorted list of TimelineEvents.
29+
* @param batch - The batch to build a timeline for
30+
* @returns A BatchTimeline with all events sorted chronologically
31+
*/
32+
export function buildBatchTimeline(batch: Batch): BatchTimeline {
33+
const events: TimelineEvent[] = [];
34+
const startMs = new Date(batch.startTime).getTime();
35+
36+
// Start event
37+
events.push({
38+
timestamp: startMs,
39+
time: new Date(batch.startTime).toISOString(),
40+
type: "start",
41+
label: `Batch started: ${batch.name} (${batch.milkType}, ${batch.milkAmount} L)`,
42+
});
43+
44+
// Cultures (no timestamp on cultures, so pin to start)
45+
for (const c of batch.cultures) {
46+
events.push({
47+
timestamp: startMs,
48+
time: new Date(batch.startTime).toISOString(),
49+
type: "culture",
50+
label: `Culture added: ${c.name} (${c.type})`,
51+
note: c.description,
52+
});
53+
}
54+
55+
// Temperature logs
56+
for (const log of batch.temperatureLogs) {
57+
const ts = new Date(log.timestamp).getTime();
58+
events.push({
59+
timestamp: ts,
60+
time: new Date(log.timestamp).toISOString(),
61+
type: "temperature",
62+
label: `Temperature: ${log.value}°C`,
63+
value: log.value,
64+
unit: "°C",
65+
note: log.note,
66+
});
67+
}
68+
69+
// pH logs
70+
for (const log of batch.phLogs) {
71+
const ts = new Date(log.timestamp).getTime();
72+
events.push({
73+
timestamp: ts,
74+
time: new Date(log.timestamp).toISOString(),
75+
type: "ph",
76+
label: `pH: ${log.value}`,
77+
value: log.value,
78+
note: log.note,
79+
});
80+
}
81+
82+
// Rennet additions
83+
for (const r of batch.rennetAdditions) {
84+
const ts = new Date(r.timestamp).getTime();
85+
events.push({
86+
timestamp: ts,
87+
time: new Date(r.timestamp).toISOString(),
88+
type: "rennet",
89+
label: `Rennet: ${r.amount} mL ${r.type}${r.strength ? ` (${r.strength} IMCU)` : ""}`,
90+
value: r.amount,
91+
unit: "mL",
92+
note: r.note,
93+
});
94+
}
95+
96+
// Pressing stages
97+
for (const p of batch.pressingStages) {
98+
const ts = new Date(p.startTime).getTime();
99+
events.push({
100+
timestamp: ts,
101+
time: new Date(p.startTime).toISOString(),
102+
type: "pressing",
103+
label: `Pressing: ${p.weight} kg for ${p.duration} min${p.flipped ? " (flipped)" : ""}`,
104+
value: p.weight,
105+
unit: "kg",
106+
note: p.note,
107+
});
108+
}
109+
110+
// Aging entries
111+
for (const a of batch.agingSchedule) {
112+
const ts = new Date(a.date).getTime();
113+
events.push({
114+
timestamp: ts,
115+
time: new Date(a.date).toISOString(),
116+
type: "aging",
117+
label: `Aging: ${a.temperature}°C${a.humidity != null ? `, ${a.humidity}% RH` : ""}${a.turned ? " (turned)" : ""}`,
118+
value: a.temperature,
119+
unit: "°C",
120+
note: a.note,
121+
});
122+
}
123+
124+
// Sort by timestamp, stable
125+
events.sort((a, b) => a.timestamp - b.timestamp);
126+
127+
// Build series
128+
const temperatureSeries = batch.temperatureLogs.map((log) => ({
129+
time: new Date(log.timestamp).toISOString(),
130+
value: log.value,
131+
}));
132+
133+
const phSeries = batch.phLogs.map((log) => ({
134+
time: new Date(log.timestamp).toISOString(),
135+
value: log.value,
136+
}));
137+
138+
// Duration
139+
const lastTs = events.length > 0 ? events[events.length - 1].timestamp : startMs;
140+
const durationMinutes = Math.max(0, Math.round((lastTs - startMs) / 60000));
141+
142+
return {
143+
batchId: batch.id,
144+
batchName: batch.name,
145+
startTime: new Date(batch.startTime).toISOString(),
146+
events,
147+
temperatureSeries,
148+
phSeries,
149+
durationMinutes,
150+
};
151+
}
152+
153+
/**
154+
* Filter timeline events by type.
155+
* @param timeline - The batch timeline
156+
* @param types - Event types to include
157+
* @returns Filtered array of timeline events
158+
*/
159+
export function filterTimelineEvents(
160+
timeline: BatchTimeline,
161+
types: ReadonlyArray<TimelineEvent["type"]>
162+
): ReadonlyArray<TimelineEvent> {
163+
const typeSet = new Set(types);
164+
return timeline.events.filter((e) => typeSet.has(e.type));
165+
}
166+
167+
/**
168+
* Generate a self-contained HTML page that visualizes the batch timeline
169+
* with interactive Chart.js charts for pH and temperature, plus an event log.
170+
* @param timeline - The batch timeline data
171+
* @returns A complete HTML string
172+
*/
173+
export function renderTimelineHTML(timeline: BatchTimeline): string {
174+
const eventsJSON = JSON.stringify(timeline.events);
175+
const tempJSON = JSON.stringify(timeline.temperatureSeries);
176+
const phJSON = JSON.stringify(timeline.phSeries);
177+
178+
return `<!DOCTYPE html>
179+
<html lang="en">
180+
<head>
181+
<meta charset="UTF-8">
182+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
183+
<title>Batch Timeline: ${escapeHTML(timeline.batchName)}</title>
184+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
185+
<style>
186+
* { box-sizing: border-box; margin: 0; padding: 0; }
187+
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
188+
h1 { text-align: center; margin-bottom: 4px; font-size: 1.6rem; }
189+
.subtitle { text-align: center; color: #888; margin-bottom: 20px; font-size: 0.9rem; }
190+
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
191+
@media (max-width: 768px) { .charts { grid-template-columns: 1fr; } }
192+
.chart-card { background: rgba(255,255,255,0.06); border-radius: 12px; padding: 16px; }
193+
.chart-card h2 { font-size: 1rem; margin-bottom: 8px; color: #aaa; }
194+
canvas { width: 100% !important; max-height: 260px; }
195+
.filters { text-align: center; margin-bottom: 16px; }
196+
.filters button { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #e0e0e0; padding: 6px 14px; border-radius: 20px; margin: 3px; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
197+
.filters button.active { background: #4361ee; border-color: #4361ee; }
198+
.filters button:hover { background: rgba(67,97,238,0.4); }
199+
.timeline { max-width: 700px; margin: 0 auto; position: relative; padding-left: 30px; }
200+
.timeline::before { content: ''; position: absolute; left: 14px; top: 0; bottom: 0; width: 2px; background: rgba(255,255,255,0.15); }
201+
.event { position: relative; margin-bottom: 12px; padding: 10px 14px; background: rgba(255,255,255,0.05); border-radius: 8px; border-left: 3px solid #4361ee; }
202+
.event[data-type="temperature"] { border-left-color: #ff6b6b; }
203+
.event[data-type="ph"] { border-left-color: #51cf66; }
204+
.event[data-type="rennet"] { border-left-color: #ffd43b; }
205+
.event[data-type="pressing"] { border-left-color: #cc5de8; }
206+
.event[data-type="aging"] { border-left-color: #20c997; }
207+
.event[data-type="culture"] { border-left-color: #ff922b; }
208+
.event[data-type="start"] { border-left-color: #4361ee; }
209+
.event .dot { position: absolute; left: -24px; top: 14px; width: 10px; height: 10px; border-radius: 50%; background: #4361ee; }
210+
.event[data-type="temperature"] .dot { background: #ff6b6b; }
211+
.event[data-type="ph"] .dot { background: #51cf66; }
212+
.event[data-type="rennet"] .dot { background: #ffd43b; }
213+
.event[data-type="pressing"] .dot { background: #cc5de8; }
214+
.event[data-type="aging"] .dot { background: #20c997; }
215+
.event[data-type="culture"] .dot { background: #ff922b; }
216+
.event .time { font-size: 0.75rem; color: #888; }
217+
.event .label { font-size: 0.9rem; margin-top: 2px; }
218+
.event .note { font-size: 0.8rem; color: #aaa; margin-top: 2px; font-style: italic; }
219+
.summary { text-align: center; color: #888; margin-top: 20px; font-size: 0.85rem; }
220+
</style>
221+
</head>
222+
<body>
223+
<h1>🧀 ${escapeHTML(timeline.batchName)}</h1>
224+
<p class="subtitle">Started ${escapeHTML(timeline.startTime)} · Duration: ${timeline.durationMinutes} min · ${timeline.events.length} events</p>
225+
226+
<div class="charts">
227+
<div class="chart-card"><h2>Temperature (°C)</h2><canvas id="tempChart"></canvas></div>
228+
<div class="chart-card"><h2>pH</h2><canvas id="phChart"></canvas></div>
229+
</div>
230+
231+
<div class="filters" id="filters"></div>
232+
<div class="timeline" id="timeline"></div>
233+
<p class="summary" id="summary"></p>
234+
235+
<script>
236+
const events = ${eventsJSON};
237+
const tempData = ${tempJSON};
238+
const phData = ${phJSON};
239+
const types = ['start','culture','temperature','ph','rennet','pressing','aging'];
240+
const activeTypes = new Set(types);
241+
242+
function initCharts() {
243+
const chartOpts = (label, color, data, yLabel) => ({
244+
type: 'line',
245+
data: {
246+
labels: data.map(d => new Date(d.time).toLocaleTimeString()),
247+
datasets: [{ label, data: data.map(d => d.value), borderColor: color, backgroundColor: color + '33', fill: true, tension: 0.3, pointRadius: 4 }]
248+
},
249+
options: {
250+
responsive: true,
251+
plugins: { legend: { display: false } },
252+
scales: {
253+
x: { ticks: { color: '#888', maxTicksLimit: 8 }, grid: { color: 'rgba(255,255,255,0.05)' } },
254+
y: { title: { display: true, text: yLabel, color: '#888' }, ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,0.05)' } }
255+
}
256+
}
257+
});
258+
if (tempData.length > 0) new Chart(document.getElementById('tempChart'), chartOpts('Temperature', '#ff6b6b', tempData, '°C'));
259+
else document.getElementById('tempChart').parentElement.innerHTML += '<p style="color:#666;text-align:center">No temperature data</p>';
260+
if (phData.length > 0) new Chart(document.getElementById('phChart'), chartOpts('pH', '#51cf66', phData, 'pH'));
261+
else document.getElementById('phChart').parentElement.innerHTML += '<p style="color:#666;text-align:center">No pH data</p>';
262+
}
263+
264+
function renderFilters() {
265+
const container = document.getElementById('filters');
266+
container.innerHTML = '';
267+
types.forEach(t => {
268+
const btn = document.createElement('button');
269+
btn.textContent = t;
270+
btn.className = activeTypes.has(t) ? 'active' : '';
271+
btn.onclick = () => { activeTypes.has(t) ? activeTypes.delete(t) : activeTypes.add(t); renderFilters(); renderTimeline(); };
272+
container.appendChild(btn);
273+
});
274+
}
275+
276+
function renderTimeline() {
277+
const container = document.getElementById('timeline');
278+
const filtered = events.filter(e => activeTypes.has(e.type));
279+
container.innerHTML = filtered.map(e => {
280+
const time = new Date(e.time).toLocaleString();
281+
return '<div class="event" data-type="' + e.type + '"><div class="dot"></div><div class="time">' + time + '</div><div class="label">' + escapeHTML(e.label) + '</div>' + (e.note ? '<div class="note">' + escapeHTML(e.note) + '</div>' : '') + '</div>';
282+
}).join('');
283+
document.getElementById('summary').textContent = 'Showing ' + filtered.length + ' of ' + events.length + ' events';
284+
}
285+
286+
function escapeHTML(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
287+
288+
initCharts(); renderFilters(); renderTimeline();
289+
</script>
290+
</body>
291+
</html>`;
292+
}
293+
294+
function escapeHTML(str: string): string {
295+
return str
296+
.replace(/&/g, "&amp;")
297+
.replace(/</g, "&lt;")
298+
.replace(/>/g, "&gt;")
299+
.replace(/"/g, "&quot;");
300+
}

0 commit comments

Comments
 (0)