Compare commits

...

23 Commits

Author SHA256 Message Date
91bcb528c1 update todo 2025-07-27 19:45:33 -04:00
d06b32c1b7 update todo 2025-07-27 11:05:44 -04:00
212dea6a7d Move storage commands to planStorage to help with improving UI in future 2025-07-27 11:04:39 -04:00
af89ca96a1 task list/todo 2025-07-27 09:20:24 -04:00
981a736821 fix git url 2025-07-13 21:56:16 -04:00
3dd3f80b04 a mostly ai generated readme 2025-07-13 21:54:36 -04:00
976fd9f647 Add the license
BSD 2 clause
2025-07-14 01:35:03 +00:00
be844bc4d0 basic save function 2025-07-13 17:05:58 -04:00
a90824efcd package updates 2025-07-13 16:51:47 -04:00
af68e8923c feedback on temp buttons 2025-07-13 16:41:35 -04:00
15f74335c7 add gray out of used buttons 2025-07-13 16:22:52 -04:00
b0024aec80 add color updates to timer for feedback 2025-07-13 16:04:46 -04:00
ae874cd665 Add development percent 2025-07-13 15:47:11 -04:00
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
14 changed files with 4214 additions and 16 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"
}
}
]
}

25
LICENSE Normal file
View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2025, Terrence Ezrol (ezterry)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

66
README.MD Normal file
View File

@@ -0,0 +1,66 @@
# Manual Coffee Roasting Plan
**Project Name:** Manual Coffee Roasting Plan
**Hosting:** [View on Gitea](https://git-hojo.devnull.name/ezterry/Manual-Coffee-Roasting-Plan)
---
### Summary
A tool to track and visualize the manual coffee roasting process, including timer functionality and potential chart-based roasting curve tracking.
---
### Installation
1. Clone the repository.
2. Install dependencies:
```bash
npm install
# or
yarn install
```
---
### Development Environment (VS Code)
1. Open the project in VS Code.
2. Ensure the following extensions are installed:
- **TypeScript** (for `.ts` file support)
- **Parcel** (for bundling)
3. Start the dev server:
```bash
npm start
# or
yarn start
```
This will serve the app at `http://localhost:1234` (default Parcel port).
---
### Deployment
Build the production-ready files:
```bash
npm run build
# or
yarn build
```
The output will be in the `dist/` folder. Deploy this folder to any static hosting service (e.g., GitHub Pages, Netlify, or Gitea's built-in static hosting).
---
### Technologies Used
- **TypeScript** (`src/roast.ts`) for type-safe logic.
- **Chart.js** and **chartjs-plugin-annotation** for potential roasting curve visualizations.
- **Parcel** for bundling and optimization.
- **PostCSS** and **Autoprefixer** for CSS compatibility.
---
### Notes
- The core logic is in toast.ts
- The graph visual is in tempgraph.ts
- roast.html is the main web app, this is all static server content but the typescript may save some local information at your request on your system
---
### Contributing
Pull requests are welcome! For major changes, please open an issue first.

40
TODO.md Normal file
View File

@@ -0,0 +1,40 @@
# TODO List Template for Project
## Overview
This template outlines the current state of the project files and future tasks to improve organization and functionality.
### Current State
#### `roast.ts`
- Manages timer functions (`startTimer`, `stopTimer`).
- Handles event logging (`logEvent`) and system reset.
- Provides plan setting and navigation through temperature buttons.
- Implements CSV export functionality.
- Supports graph display and manipulation.
- Manages saved roast plans (save, load, clear).
#### `roast.html`
- Defines the user interface for coffee roast logging.
- Includes input fields for setting roast plans and saving them.
- Contains control elements to manage events and export data.
#### `roast.css`
- Styles the timer display with phase-specific backgrounds.
- Formats buttons and containers for a cohesive visual layout.
- Ensures responsiveness and accessibility across devices.
### Future Tasks
1. **Enhance User Interface:**
- Update visual styling for better user experience in `roast.css`.
- Implement a popup library window for managing roast plans, allowing users to save, restore, edit, and delete plans.
- Integrate access to hard-coded library plans that can be added to the user's saved list if the online library changes.
2. **Expand Functionality:**
- Add information about the rate of rise during roasting.
- Capture details about the roast (start/end weight).
- Implement a favorite button for quick access to frequently used plans.
3. **Documentation and Testing:**
- Create comprehensive documentation for all features.
- Develop test cases to ensure robustness of functionality.

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

3249
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"dependencies": {
"chart.js": "^4.4.1",
"chartjs-plugin-annotation": "^3.1.0"
},
"devDependencies": {
"@types/node": "^22.16.3",
"autoprefixer": "^10.4.21",
"parcel": "^2.15.4",
"postcss": "^8.5.6",
"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"
}
}

74
src/planStorage.ts Normal file
View File

@@ -0,0 +1,74 @@
// planStorage.ts
/**
* Saves a roast plan to local storage.
*
* @param name The name of the plan.
* @param temperatures A comma-separated string of temperature targets.
*/
export function savePlan(name: string, temperatures: string): void {
if (!name) {
throw new Error("Please provide a valid name for the roast plan.");
}
const trimmedTemperatures = temperatures.trim();
if (!trimmedTemperatures) {
throw new Error("Temperature list is empty.");
}
// Parse and validate temperature input
const parsedTemps = trimmedTemperatures.split(',').map(x => x.trim()).map(Number);
if (parsedTemps.some(val => isNaN(val))) {
throw new Error("Invalid temperature list. Please enter comma-separated numbers.");
}
localStorage.setItem(`roastplan_${name}`, trimmedTemperatures);
}
/**
* Loads a saved roast plan from local storage.
*
* @param name The name of the plan to load.
* @returns A string containing the temperature targets, or null if not found.
*/
export function retrievePlan(name: string): string | null {
if (!name) return null;
const stored = localStorage.getItem(`roastplan_${name}`);
return stored || null;
}
/**
* Deletes all saved roast plans from local storage.
*/
export function clearAllPlans(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("roastplan_")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}
/**
* Lists all saved roast plan names from local storage.
*
* @returns An array of strings representing the names of saved plans.
*/
export function listSavedPlans(): string[] {
const savedPlanNames: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("roastplan_")) {
const planName = key.replace("roastplan_", "");
savedPlanNames.push(planName);
}
}
return savedPlanNames;
}

222
src/roast.css Normal file
View File

@@ -0,0 +1,222 @@
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;
}
/* Roast phase backgrounds */
#timer.initial-phase {
background-color: #ffffff; /* white before Charge */
}
#timer.charge-phase {
background-color: #e6ffe6; /* pale green after Charge */
}
#timer.yellow-phase {
background-color: #fffdd0; /* light yellow after Yellow */
}
/* Dynamic muddy yellow-brown transition (040% dev) */
#timer.dev-phase {
transition: background-color 0.5s ease;
}
#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.activated {
background-color: #ccc; /* gray to indicate completed step */
color: #333;
border: 2px solid #888;
}
#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;
}
.used-temp {
background-color: #ccc !important;
color: #333;
border: 2px solid #888;
cursor: default;
}
#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;
}

68
src/roast.html Normal file
View File

@@ -0,0 +1,68 @@
<!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">
<span id="roastTime">0:00</span>
<span id="devPercent" style="display: none;"> (0%)</span>
</div>
<div id="create-plan">
<input type="text" id="plan-input" placeholder="Enter temperatures (comma separated)">
<button onclick="setPlan()">Set Plan</button>
<!-- Save and Load Plan UI -->
<div id="plan-storage" style="margin-top: 16px;">
<input type="text" id="save-name" placeholder="Save plan as..." style="width: 60%; padding: 8px;">
<button onclick="saveCurrentPlan()" style="padding: 8px 16px;">Save Plan</button>
<br><br>
<button onclick="clearSavedPlans()" style="margin-top: 10px; padding: 6px 16px;">Clear All Plans</button>
<label for="saved-plans">Load a saved plan:</label>
<select id="saved-plans" onchange="loadSavedPlan(this.value)" style="margin-left: 8px; padding: 6px;">
<option value="">-- Select saved plan --</option>
</select>
</div>
</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,15 +1,41 @@
import { loadGraph } from './tempgraph.ts';
import { savePlan, retrievePlan, clearAllPlans, listSavedPlans } from './planStorage.ts';
let timer = 0;
let isRunning = false;
let log: Array<{ time: string, event: string, temp?: number }> = [];
let targets: number[] = [];
let currentTargetIndex = 0;
let interval: NodeJS.Timeout | null = null;
let firstCrackTime: number | null = null;
let loggedTemps = new Set<number>();
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;
saveCurrentPlan: () => void;
loadSavedPlan: (name: string) => void;
clearSavedPlans: () => 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,48 +44,117 @@ function startTimer(): void {
}
function stopTimer(): void {
clearInterval(interval!);
if (interval !== null) {
clearInterval(interval);
}
isRunning = false;
}
function updateTimerDisplay(): void {
const roastTimeEl = document.getElementById("roastTime")!;
const devPercentEl = document.getElementById("devPercent")!;
const minutes = Math.floor(timer / 60);
const seconds = timer % 60;
document.getElementById("timer")!.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
roastTimeEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
if (firstCrackTime !== null && timer > firstCrackTime) {
const devTime = timer - firstCrackTime;
const devRatio = Math.floor((devTime / timer) * 100);
devPercentEl.textContent = ` (${devRatio}%)`;
devPercentEl.style.display = 'inline';
} else {
devPercentEl.style.display = 'none';
}
const timerEl = document.getElementById("timer")!;
timerEl.className = "";
if (!log.some(e => e.event === "Charge")) {
timerEl.classList.add("initial-phase");
} else if (!log.some(e => e.event === "Yellow")) {
timerEl.classList.add("charge-phase");
} else if (firstCrackTime === null) {
timerEl.classList.add("yellow-phase");
} else {
timerEl.classList.add("dev-phase");
const devTime = timer - firstCrackTime;
const devRatio = Math.min(devTime / timer, 0.4);
const r = Math.round(255 - devRatio * 100);
const g = Math.round(255 - devRatio * 150);
const b = Math.round(208 - devRatio * 125);
timerEl.style.backgroundColor = `rgb(${r},${g},${b})`;
}
}
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();
updateEventButtons(eventName);
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");
const tempButtons = document.getElementById("temp-buttons")!;
tempButtons.innerHTML = "";
document.getElementById("create-plan")!.classList.remove("hidden");
document.getElementById("temp-buttons")!.innerHTML = "";
updateEventButtons(eventName);
return;
}
log.push({ time: formatTime(timer), event: eventName, temp: null });
if (eventName === "First Crack") {
firstCrackTime = timer;
}
log.push({ time: formatTime(timer), event: eventName });
updateLog();
updateEventButtons(eventName);
}
function updateEventButtons(eventName: string): void {
const eventSequence = ["Charge", "Yellow", "First Crack", "Second Crack", "Drop"];
if (eventSequence.includes(eventName)) {
const pressedIndex = eventSequence.indexOf(eventName);
for (let i = 0; i <= pressedIndex; i++) {
const btn = document.querySelector(`#event-buttons button:nth-child(${i + 1})`) as HTMLButtonElement;
if (btn) btn.classList.add("activated");
}
} else if (eventName === "Reset") {
document.querySelectorAll("#event-buttons button").forEach(btn =>
btn.classList.remove("activated")
);
}
}
function resetSystem(): void {
stopTimer();
log = [];
timer = 0;
firstCrackTime = null;
loggedTemps.clear();
updateLog();
document.getElementById("create-plan")!.classList.add("hidden");
const tempButtons = document.getElementById("temp-buttons")!;
tempButtons.innerHTML = "";
updateTimerDisplay();
const devPercentEl = document.getElementById("devPercent")!;
devPercentEl.style.display = "none";
const timerEl = document.getElementById("timer")!;
timerEl.className = "initial-phase";
timerEl.style.backgroundColor = "";
document.getElementById("create-plan")!.classList.remove("hidden");
document.getElementById("temp-buttons")!.innerHTML = "";
updateEventButtons("Reset");
}
function formatTime(seconds: number): string {
@@ -80,17 +175,24 @@ function setPlan(): void {
function generateTempButtons(): void {
const container = document.getElementById("temp-buttons")!;
container.innerHTML = "";
if (targets.length === 0) {
container.innerHTML = "<p>No temperature plan set.</p>";
return;
}
const currentTarget = targets[currentTargetIndex];
const buttons: string[] = [];
buttons.push('<button class="nav-btn" onclick="navigateTemp(-1);">&lt;&lt;</button>');
for (let i = -2; i <= 2; i++) {
const temp = currentTarget + i;
buttons.push(`<button onclick="logTemp(${temp})">${temp}</button>`);
const used = loggedTemps.has(temp);
const classStr = used ? 'used-temp' : '';
buttons.push(`<button onclick="logTemp(${temp})" class="${classStr}">${temp}</button>`);
}
buttons.push('<button class="nav-btn" onclick="navigateTemp(1);">&gt;&gt;</button>');
container.innerHTML = buttons.join(" ");
}
@@ -107,6 +209,7 @@ function navigateTemp(direction: number): void {
function logTemp(temp: number): void {
log.push({ time: formatTime(timer), event: "Temp", temp });
loggedTemps.add(temp);
updateLog();
currentTargetIndex++;
if (currentTargetIndex >= targets.length) {
@@ -126,8 +229,20 @@ function updateLog(): void {
`).join("");
}
function createCSVData(): string[] {
const header = "Time,Event,Temperature";
const logEntries = log.map(entry =>
`${entry.time},${entry.event}${entry.temp !== null ? `,${entry.temp}` : ''}`
);
return [header, ...logEntries];
}
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 +251,117 @@ 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");
}
}
function saveCurrentPlan(): void {
const nameInput = document.getElementById("save-name") as HTMLInputElement;
const planName = nameInput.value.trim();
const rawInput = (document.getElementById("plan-input") as HTMLInputElement).value.trim();
try {
savePlan(planName, rawInput);
alert(`Plan "${planName}" saved successfully.`);
} catch (error) {
alert(error.message);
}
nameInput.value = "";
updateSavedPlansDropdown();
}
function loadSavedPlan(name: string): void {
const stored = retrievePlan(name);
if (stored) {
targets = stored.split(",").map(Number);
currentTargetIndex = 0;
document.getElementById("controls")!.classList.remove("hidden");
document.getElementById("create-plan")!.classList.add("hidden");
// Set the plan in the text box
console.log("Set plan: " +stored);
const planInput = document.getElementById("plan-input") as HTMLInputElement;
const savedName = document.getElementById("save-name") as HTMLInputElement;
planInput.value = stored;
savedName.value = name;
generateTempButtons();
// Reset the input dropdown to the first (choose prompt) entry
updateSavedPlansDropdown();
} else {
alert(`No plan found with the name "${name}".`);
}
}
function updateSavedPlansDropdown(): void {
const dropdown = document.getElementById("saved-plans") as HTMLSelectElement;
// Retrieve saved plans from planStorage.ts
const savedPlans = listSavedPlans();
// Reset to default prompt
dropdown.innerHTML = '<option value="">-- Select Saved Plan --</option>';
// Populate the dropdown with saved plans
savedPlans.forEach(planName => {
const option = document.createElement('option');
option.value = planName;
option.textContent = planName;
dropdown.appendChild(option);
});
// Set the first entry as selected if available
if (savedPlans.length > 0) {
dropdown.selectedIndex = 0; // Index 1 corresponds to the first actual plan
}
}
function clearSavedPlans(): void {
const confirmClear = confirm("Are you sure you want to delete all saved roast plans?");
if (!confirmClear) return;
clearAllPlans();
updateSavedPlansDropdown();
alert("All saved roast plans have been deleted.");
}
window.addEventListener("DOMContentLoaded", () => {
updateSavedPlansDropdown();
});
// Include in window export
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;
window.saveCurrentPlan = saveCurrentPlan;
window.loadSavedPlan = loadSavedPlan;
window.clearSavedPlans = clearSavedPlans;
}
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
}
}
}
});
}