diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json
index 96a5322..f381d31 100644
--- a/frontend/node_modules/.package-lock.json
+++ b/frontend/node_modules/.package-lock.json
@@ -48,7 +48,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -522,7 +521,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -642,7 +640,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -1342,7 +1339,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1370,7 +1366,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -1411,7 +1406,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1421,7 +1415,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1645,7 +1638,6 @@
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ecddae2..cf8ae9f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -72,7 +72,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1465,7 +1464,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1585,7 +1583,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2495,7 +2492,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2523,7 +2519,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2564,7 +2559,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2574,7 +2568,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2798,7 +2791,6 @@
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx
index 7e32098..17db358 100644
--- a/frontend/src/pages/Portfolio.tsx
+++ b/frontend/src/pages/Portfolio.tsx
@@ -2,6 +2,13 @@ import { motion } from 'framer-motion';
const Portfolio = () => {
const projects = [
+ {
+ title: 'DTE Rates for Home Assistant',
+ description: 'A Home Assistant custom integration that pulls the official DTE residential electric rate card PDF, parses rates dynamically, and exposes import/export price entities that track time-of-day and season.',
+ tech: ['Python', 'Home Assistant', 'HACS', 'Lovelace'],
+ gradient: 'from-yellow-500 to-orange-500',
+ link: 'https://github.com/javaDevJT/DTE-Rates-for-Home-Assistant'
+ },
{
title: 'Interactive Dashboard',
description: 'A sleek dashboard for monitoring automotive performance data with real-time visualizations.',
@@ -81,13 +88,26 @@ const Portfolio = () => {
))}
-
- View Project
-
+ {project.link ? (
+
+ View Project
+
+ ) : (
+
+ View Project
+
+ )}
))}
diff --git a/src/main/java/com/jtdev/website/service/ContentService.java b/src/main/java/com/jtdev/website/service/ContentService.java
index 09ed447..b3c0b50 100644
--- a/src/main/java/com/jtdev/website/service/ContentService.java
+++ b/src/main/java/com/jtdev/website/service/ContentService.java
@@ -2,6 +2,7 @@
import com.jtdev.website.model.BlogMetadata;
import com.jtdev.website.model.PortfolioMetadata;
+import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
@@ -72,6 +73,7 @@ public String getMarkdownContent(String path) throws IOException {
// Parse markdown and convert to ASCII-friendly format
MutableDataSet options = new MutableDataSet();
+ options.set(Parser.EXTENSIONS, java.util.Arrays.asList(TablesExtension.create()));
Parser parser = Parser.builder(options).build();
HtmlRenderer renderer = HtmlRenderer.builder(options).build();
@@ -88,9 +90,12 @@ public String getMarkdownContent(String path) throws IOException {
private String convertHtmlToAscii(String html, String dir) {
// First pass: Convert headers with dynamic borders
html = convertDynamicHeaders(html);
-
+
html = formatNestedLists(html);
+ // Convert tables before the general regex pass
+ html = convertTablesToAscii(html);
+
// Convert HTML elements to ASCII formatting
String text = html
// Headers already converted above
@@ -124,17 +129,7 @@ private String convertHtmlToAscii(String html, String dir) {
// Bold and italic
.replaceAll("]*>([^<]*)", "**$1**")
- .replaceAll("]*>([^<]*)", "*$1*")
-
- // Tables (simplified ASCII representation)
- .replaceAll("
]*>", "\n┌")
- .replaceAll("
", "┘\n")
- .replaceAll("]*>", "")
- .replaceAll("
", "\n├")
- .replaceAll("]*>", "─ ")
- .replaceAll(" | ", " ─")
- .replaceAll("]*>", "│ ")
- .replaceAll(" | ", " │");
+ .replaceAll("]*>([^<]*)", "*$1*");
// Handle images with ASCII art
Pattern imgPattern = Pattern.compile("
]*src=\"([^\"]*)\"[^>]*>");
@@ -150,8 +145,8 @@ private String convertHtmlToAscii(String html, String dir) {
// Handle images without src
text = text.replaceAll("
]*>", "[Image]");
- text
+ text = text
// Remove any remaining HTML tags
.replaceAll("<[^>]+>", "")
@@ -162,8 +157,7 @@ private String convertHtmlToAscii(String html, String dir) {
.replaceAll("\\n{3,}", "\n\n")
.trim();
- // The formatting is already handled in the regex replacements above
- return text.trim();
+ return text;
}
private String formatNestedLists(String html) {
@@ -250,6 +244,90 @@ int nextIndex() {
}
}
+ private String convertTablesToAscii(String html) {
+ Pattern tablePattern = Pattern.compile("", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+ Matcher tableMatcher = tablePattern.matcher(html);
+ StringBuffer sb = new StringBuffer();
+ while (tableMatcher.find()) {
+ String asciiTable = renderTableAsAscii(tableMatcher.group(1));
+ tableMatcher.appendReplacement(sb, Matcher.quoteReplacement(asciiTable));
+ }
+ tableMatcher.appendTail(sb);
+ return sb.toString();
+ }
+
+ private String renderTableAsAscii(String tableHtml) {
+ List> rows = new ArrayList<>();
+
+ Pattern rowPattern = Pattern.compile("]*>(.*?)
", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+ Matcher rowMatcher = rowPattern.matcher(tableHtml);
+ while (rowMatcher.find()) {
+ List cells = new ArrayList<>();
+ Pattern cellPattern = Pattern.compile("]*>(.*?)", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+ Matcher cellMatcher = cellPattern.matcher(rowMatcher.group(1));
+ while (cellMatcher.find()) {
+ String cell = cellMatcher.group(1)
+ .replaceAll("<[^>]+>", "")
+ .replaceAll("&", "&").replaceAll("<", "<")
+ .replaceAll(">", ">").replaceAll(""", "\"")
+ .replaceAll("'", "'").replaceAll(" ", " ")
+ .trim();
+ cells.add(cell);
+ }
+ if (!cells.isEmpty()) rows.add(cells);
+ }
+
+ if (rows.isEmpty()) return "";
+
+ int numCols = rows.stream().mapToInt(List::size).max().orElse(0);
+ int[] colWidths = new int[numCols];
+ for (List row : rows) {
+ for (int i = 0; i < row.size(); i++) {
+ colWidths[i] = Math.max(colWidths[i], row.get(i).length());
+ }
+ }
+
+ StringBuilder ascii = new StringBuilder("\n");
+
+ // Top border
+ ascii.append("┌");
+ for (int i = 0; i < numCols; i++) {
+ ascii.append("─".repeat(colWidths[i] + 2));
+ ascii.append(i < numCols - 1 ? "┬" : "┐");
+ }
+ ascii.append("\n");
+
+ for (int r = 0; r < rows.size(); r++) {
+ List row = rows.get(r);
+ ascii.append("│");
+ for (int i = 0; i < numCols; i++) {
+ String cell = i < row.size() ? row.get(i) : "";
+ ascii.append(" ").append(cell).append(" ".repeat(colWidths[i] - cell.length() + 1)).append("│");
+ }
+ ascii.append("\n");
+
+ if (r == 0 && rows.size() > 1) {
+ // Header separator
+ ascii.append("├");
+ for (int i = 0; i < numCols; i++) {
+ ascii.append("─".repeat(colWidths[i] + 2));
+ ascii.append(i < numCols - 1 ? "┼" : "┤");
+ }
+ ascii.append("\n");
+ } else if (r == rows.size() - 1) {
+ // Bottom border
+ ascii.append("└");
+ for (int i = 0; i < numCols; i++) {
+ ascii.append("─".repeat(colWidths[i] + 2));
+ ascii.append(i < numCols - 1 ? "┴" : "┘");
+ }
+ ascii.append("\n");
+ }
+ }
+
+ return ascii.toString();
+ }
+
private String convertDynamicHeaders(String html) {
// Convert H1 headers with dynamic borders
Pattern h1Pattern = Pattern.compile("]*>(.*?)
", Pattern.DOTALL);
diff --git a/src/main/resources/directories/portfolio/DTE-Rates-for-Home-Assistant.md b/src/main/resources/directories/portfolio/DTE-Rates-for-Home-Assistant.md
new file mode 100644
index 0000000..092d125
--- /dev/null
+++ b/src/main/resources/directories/portfolio/DTE-Rates-for-Home-Assistant.md
@@ -0,0 +1,71 @@
+title: DTE-Rates-for-Home-Assistant
+type: Home Automation
+year: 2026
+technologies: Python, Home Assistant, HACS, Lovelace, PDF Parsing, Custom Integration
+
+# DTE Rates for Home Assistant
+
+## Real-Time DTE Electric Rates in Your Smart Home
+
+DTE Rates for Home Assistant is a custom integration that pulls the official DTE residential electric rate card PDF, parses plans and time-of-use windows dynamically, and exposes import/export price sensor entities that update based on time-of-day and season.
+
+## Why It Matters
+- Eliminates manually hardcoding rate schedules that change with every DTE tariff update.
+- Enables energy automations and dashboards to react to live pricing (off-peak charging, export decisions, cost tracking).
+- Integrates directly with the Home Assistant Energy Dashboard as a `USD/kWh` price entity.
+- HACS-installable — no manual file copying required for most users.
+
+## What It Exposes
+
+| Entity | Type | Description |
+|---|---|---|
+| `sensor.dte_import_rate` | monetary | Current import price (USD/kWh) |
+| `sensor.dte_export_rate` | monetary | Current export/generation price (USD/kWh) |
+| `sensor.dte_current_rate_name` | string | Active period name (e.g. `Winter Off-Peak`) |
+| `sensor.dte_rate_schedule` | structured | Full parsed schedule with attributes for dashboards |
+
+## Architecture
+```
+DTE Rate Card PDF (dteenergy.com)
+ ↓ (weekly refresh)
+PDF Parser → Plan/Period/Window/Component extraction
+ ↓
+Home Assistant Config Flow (rate plan + net metering selection)
+ ↓
+Sensor entities (updated on time boundary events)
+ ↓
+Energy Dashboard / Automations / Lovelace Cards
+```
+
+## Key Features
+- **Dynamic PDF parsing:** Rates are parsed live from the official DTE PDF — no hardcoded values that go stale.
+- **Time-of-use awareness:** Sensors reflect the correct rate for the current time window and season automatically.
+- **Net metering support:** Export calculation adjusts based on whether net metering is enabled.
+- **Warning system:** Persistent notifications and `warning` attributes fire when a previously selected rate plan disappears from the latest card.
+- **Custom Lovelace card:** Ships a `custom:dte-rates-card` for at-a-glance rate display on any dashboard.
+- **Service calls:** Force refresh, inspect the parsed schedule, and generate card YAML examples via HA services.
+
+## Custom Lovelace Card
+```yaml
+type: custom:dte-rates-card
+title: DTE Residential Rates
+import_entity: sensor.dte_import_rate
+export_entity: sensor.dte_export_rate
+name_entity: sensor.dte_current_rate_name
+schedule_entity: sensor.dte_rate_schedule
+```
+
+## Services
+
+| Service | Description |
+|---|---|
+| `dte_rates.refresh_rate_card` | Force re-download and re-parse of the DTE PDF |
+| `dte_rates.show_rate_schedule` | Dumps parsed schedule to a persistent notification |
+| `dte_rates.show_lovelace_card_example` | Generates card resource + YAML example notification |
+
+## Status
+- **State:** Active, publicly available via HACS.
+- **Role:** Full design + implementation (PDF parsing pipeline, HA integration, config flow, Lovelace card).
+- **Next Up:** Support for additional DTE rate plans, multi-utility expansion.
+
+[GitHub](https://github.com/javaDevJT/DTE-Rates-for-Home-Assistant)