Compare commits

...

10 Commits

Author SHA256 Message Date
7f402fcade fix range of graph 2025-07-13 14:17:08 -04:00
6d0d27511f send graph to display 2025-07-13 14:03:49 -04:00
9784ff4f26 fix graph visuals 2025-07-13 13:09:09 -04:00
96589039a4 trigger drawing screen 2025-07-13 12:35:58 -04:00
a02eae32a1 fix hide/show graph window 2025-07-13 12:13:59 -04:00
c7c5df68d3 ignore vscode 2025-07-13 01:22:08 -04:00
8dd57d4b16 update show graph 2025-07-13 01:20:43 -04:00
ef35a36512 fix version of parcel 2025-06-10 19:05:07 -04:00
a711acbea8 basic initial code 2025-06-09 00:21:41 -04:00
9f99495eab ignore node files 2025-06-08 10:05:52 -04:00
10 changed files with 18666 additions and 10 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
/.cache
/.parcel-cache
/.vscode

13
.terserrc Normal file
View File

@@ -0,0 +1,13 @@
{
"mangle": {
"reserved": [
"setPlan",
"logEvent",
"exportCSV",
"resetSystem",
"navigateTemp",
"logTemp",
"showGraph"
]
}
}

44
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,44 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build TypeScript and Bundle Static Site",
"type": "shell",
"command": "cd ${workspaceFolder}; source nodetopath.sh; npm run build",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always"
}
},
{
"label": "Clean site",
"type": "shell",
"command": "cd ${workspaceFolder}; rm -rf dist",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always"
}
},
{
"label": "Run Site",
"type": "shell",
"command": "cd ${workspaceFolder}; source nodetopath.sh; npm run start",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always"
}
}
]
}

View File

@@ -3,5 +3,10 @@
#source node binaries
. nodetopath.sh
npm install -g typescript
npm install -g ts-node
npm install --save-dev @types/node
#npm install --save-dev typescript parcel-bundler
npm i --save-dev parcel
npm install --save-dev autoprefixer postcss
npm install chart.js@4.4.1
npm install chartjs-plugin-annotation@3.1.0

18118
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"dependencies": {
"chart.js": "^4.4.1",
"chartjs-plugin-annotation": "^3.1.0"
},
"devDependencies": {
"@types/node": "^22.15.31",
"parcel": "^2.15.2",
"typescript": "^5.8.3"
},
"scripts": {
"start": "parcel src/roast.html",
"build": "parcel build src/roast.html --public-url=./ --dist-dir dist"
},
"compilerOptions": {
"moduleResolution": "node",
"sourceMap": true,
"module": "ESNext"
}
}

189
src/roast.css Normal file
View File

@@ -0,0 +1,189 @@
body {
background-color: #e6ccff;
font-family: Arial, sans-serif;
color: #333;
margin: 0;
padding: 0;
}
#timer {
font-size: 48px;
text-align: center;
padding: 20px;
background-color: #fff;
border: 2px solid #666;
margin: 0;
}
#create-plan {
padding: 20px;
text-align: center;
}
#create-plan input {
width: 80%;
padding: 10px;
font-size: 18px;
}
#create-plan button {
margin-top: 10px;
padding: 10px 20px;
font-size: 18px;
}
#controls {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.button-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin: 10px;
}
.button-group button:not(.nav-btn) {
font-size: 20px;
border: 2px solid #666;
background-color: #fff;
color: #333;
border-radius: 10px;
cursor: pointer;
}
.button-group .nav-btn {
padding: 15px 25px;
font-size: 20px;
border: 2px solid #666;
background-color: #E8E;
color: #333;
border-radius: 10px;
cursor: pointer;
}
.button-group button:active {
background-color: #ddd;
}
#event-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin: 20px;
}
#event-buttons button {
padding: 15px 25px;
font-size: 20px;
border: 2px solid #666;
background-color: #afa;
color: #333;
border-radius: 10px;
cursor: pointer;
}
#event-buttons button:active {
background-color: #ddd;
}
#log-table {
width: 100%;
border-collapse: collapse;
}
#log-table table {
table-layout: fixed;
width: 90%;
margin: auto;
}
#log-table th,
#log-table td {
border: 1px solid #666;
padding: 10px;
text-align: center;
}
#log-table th {
background-color: #f0e6ff;
}
#export-csv {
margin: 20px;
text-align: center;
}
#export-csv button {
padding: 10px 20px;
font-size: 18px;
border: 2px solid #666;
background-color: #fff;
color: #333;
border-radius: 10px;
cursor: pointer;
}
#export-csv button:active {
background-color: #ddd;
}
.hidden {
display: none;
}
#temp-buttons {
margin: 8px;
}
#temp-buttons button {
text-align: center;
width: 3.5em;
}
#graph-container {
position: fixed; /* Centered and float over the screen */
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Aligns perfectly center */
width: 100%;
max-width: 800px;
height: 400px;
justify-content: center;
align-items: center;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 1000; /* Ensures it's above other elements */
}
.graph-display-flex {
display: flex;
}
canvas {
width: 100%;
height: 100%;
}
#close-btn {
position: absolute;
top: 10px;
right: 10px;
background-color: #fff;
border: 1px solid #ccc;
padding: 5px 10px;
font-size: 16px;
cursor: pointer;
z-index: 10;
border-radius: 4px;
}
#close-btn:hover {
background-color: #f8f8f8;
}

49
src/roast.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stovetop Coffee Roast Logger</title>
<link rel="stylesheet" href="./roast.css">
</head>
<body>
<div id="timer">0:00</div>
<div id="create-plan">
<input type="text" id="plan-input" placeholder="Enter temperatures (comma separated)">
<button onclick="setPlan()">Set Plan</button>
</div>
<div id="controls" class="hidden">
<div id="temp-buttons" class="button-group"></div>
<div id="event-buttons" class="button-group">
<button onclick="logEvent('Charge')">Charge</button>
<button onclick="logEvent('Yellow')">Yellow</button>
<button onclick="logEvent('First Crack')">First Crack</button>
<button onclick="logEvent('Second Crack')">Second Crack</button>
<button onclick="logEvent('Drop')">Drop</button>
</div>
<div id="export-csv">
<button onclick="exportCSV()">Export CSV</button>
<button onclick="showGraph()">Show Graph</button>
<button onclick="resetSystem()">Reset</button>
</div>
</div>
<div id="log-table">
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Temperature</th>
</tr>
</thead>
<tbody id="log-body">
<!-- Log entries will be inserted here -->
</tbody>
</table>
</div>
<div id="graph-container" class="hidden">
<button onclick="hideGraph()" id="close-btn">X</button>
<canvas id="myChart"></canvas>
</div>
<!--script type="module" src="./tempgraph.ts"></script-->
<script type="module" src="./roast.ts"></script></body>
</html>

View File

@@ -1,3 +1,5 @@
import { loadGraph } from './tempgraph.ts';
let timer = 0;
let isRunning = false;
let log: Array<{ time: string, event: string, temp?: number }> = [];
@@ -5,11 +7,29 @@ let targets: number[] = [];
let currentTargetIndex = 0;
let interval: NodeJS.Timeout | null = null;
declare global {
interface Window {
startTimer: () => void;
stopTimer: () => void;
logEvent: (eventName: string) => void;
resetSystem: () => void;
setPlan: () => void;
navigateTemp: (direction: number) => void;
logTemp: (temp: number) => void;
exportCSV: () => void;
showGraph: () => void;
hideGraph: () => void;
}
}
function startTimer(): void {
if (isRunning) {
alert("Timer is already running. Click again to reset.");
return;
}
timer = 0;
updateTimerDisplay();
isRunning = true;
interval = setInterval(() => {
timer++;
@@ -18,7 +38,9 @@ function startTimer(): void {
}
function stopTimer(): void {
clearInterval(interval!);
if(interval !== null){
clearInterval(interval);
}
isRunning = false;
}
@@ -30,26 +52,28 @@ function updateTimerDisplay(): void {
function logEvent(eventName: string): void {
if (eventName === "Charge") {
if (log.length > 0) {
if (log.length > 0 || isRunning) {
if(isRunning){
stopTimer();
}
log = [];
timer = 0;
isRunning = false;
}
startTimer();
log.push({ time: formatTime(timer), event: eventName, temp: null });
log.push({ time: formatTime(timer), event: eventName });
updateLog();
return;
}
if (eventName === "Drop") {
stopTimer();
log.push({ time: formatTime(timer), event: eventName, temp: null });
log.push({ time: formatTime(timer), event: eventName });
updateLog();
document.getElementById("create-plan")!.classList.add("hidden");
document.getElementById("create-plan")!.classList.remove("hidden");
const tempButtons = document.getElementById("temp-buttons")!;
tempButtons.innerHTML = "";
return;
}
log.push({ time: formatTime(timer), event: eventName, temp: null });
log.push({ time: formatTime(timer), event: eventName });
updateLog();
}
@@ -57,7 +81,7 @@ function resetSystem(): void {
stopTimer();
log = [];
updateLog();
document.getElementById("create-plan")!.classList.add("hidden");
document.getElementById("create-plan")!.classList.remove("hidden");
const tempButtons = document.getElementById("temp-buttons")!;
tempButtons.innerHTML = "";
}
@@ -126,8 +150,26 @@ function updateLog(): void {
`).join("");
}
// src/roast.ts
/**
* Generates an array of CSV strings line by line from log data.
*/
function createCSVData(): string[] {
const header = "Time,Event,Temperature";
const logEntries = log.map(entry => `${entry.time},${entry.event}${entry.temp !== null ? `,${entry.temp}` : ''}`);
return [header].concat(logEntries);
}
/**
* Creates CSV format content as a single string from the array returned by createCSVData().
*/
function generateCSVContent(): string {
return createCSVData().join("\n");
}
function exportCSV(): void {
const csvContent = "Time,Event,Temperature\n" + log.map(entry => `${entry.time},${entry.event},${entry.temp !== null ? entry.temp : ''}`).join("\n");
const csvContent = generateCSVContent();
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.setAttribute("href", URL.createObjectURL(blob));
@@ -136,4 +178,37 @@ function exportCSV(): void {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
function showGraph(): void {
const graphContainer = document.getElementById("graph-container");
if (graphContainer) {
loadGraph(createCSVData());
graphContainer.classList.remove("hidden");
graphContainer.classList.add("graph-display-flex");
}
}
function hideGraph(): void {
const graphContainer = document.getElementById("graph-container");
if (graphContainer) {
graphContainer.classList.add("hidden");
graphContainer.classList.remove("graph-display-flex");
}
}
// Expose functions to the global scope (allowing html direct access)
if (typeof window !== 'undefined') {
window.startTimer = startTimer;
window.stopTimer = stopTimer;
window.logEvent = logEvent;
window.resetSystem = resetSystem;
window.setPlan = setPlan;
window.navigateTemp = navigateTemp;
window.logTemp = logTemp;
window.exportCSV = exportCSV;
window.showGraph = showGraph;
window.hideGraph = hideGraph;
}
export {};

137
src/tempgraph.ts Normal file
View File

@@ -0,0 +1,137 @@
import { Chart, registerables } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { AnnotationOptions } from 'chartjs-plugin-annotation';
// Register Chart.js components and plugins
Chart.register(...registerables);
Chart.register(annotationPlugin);
// External chart instance tracker
let chartInstance: Chart | null = null;
/* Test data if nothing is provided */
function getTestData(): string[] {
const sampleData = `Time,Event,Temperature
0:00,Charge,
0:00,Temp,139
0:03,Temp,90
0:10,Temp,129
0:13,Temp,161
0:19,Temp,191
0:22,Yellow,
0:24,Temp,207
0:24,First Crack,
0:30,Temp,208
0:31,Drop,`;
return sampleData.split('\n');
}
function parseTime(time: string): number {
const [mins, secs] = time.split(':').map(Number);
return mins * 60 + secs;
}
export function loadGraph(dataArray?: string[]): void {
const lines = dataArray || getTestData();
const data = lines.slice(1).map(line => {
const [time, event, temp] = line.split(',');
return { time, event, temp };
});
// Compute dynamic max time based on all entries
const maxEventTime = data.reduce((max, entry) => {
return entry.time ? Math.max(max, parseTime(entry.time)) : max;
}, 0);
const tempData = data
.filter(entry => entry.event === 'Temp')
.map(entry => {
const timeInSeconds = parseTime(entry.time);
const temp = parseFloat(entry.temp);
return { x: timeInSeconds, y: temp };
});
// Extend tempData to ensure final Drop timestamp is visible
const lastTemp = tempData[tempData.length - 1];
const extendedTempData = lastTemp ? tempData.concat({ x: maxEventTime, y: lastTemp.y }) : tempData;
const ctx = document.getElementById('myChart') as HTMLCanvasElement;
ctx.width = 800;
ctx.height = 400;
const annotationsArray: AnnotationOptions<'line'>[] = data
.filter(entry => entry.event !== 'Temp')
.map(entry => {
const timeInSeconds = parseTime(entry.time);
return {
type: 'line',
mode: 'vertical',
scaleID: 'x',
value: timeInSeconds,
borderColor: 'red',
borderWidth: 2,
label: {
content: entry.event,
display: true,
rotation: -90,
position: 'end',
yAdjust: -10,
font: {
size: 10
}
}
};
});
// Clean up previous chart instance if exists
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: 'Temperature (°C)',
data: extendedTempData,
borderColor: 'blue',
fill: false,
pointRadius: 5,
pointBackgroundColor: 'blue'
}]
},
options: {
animation: {
duration: 0
},
responsive: true,
scales: {
x: {
type: 'linear',
position: 'bottom',
min: 0,
max: maxEventTime,
ticks: {
callback: function (value) {
const mins = Math.floor(Number(value) / 60);
const secs = Number(value) % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
},
y: {
title: {
display: true,
text: 'Temperature (°C)'
}
}
},
plugins: {
annotation: {
annotations: annotationsArray
}
}
}
});
}