Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ A progress bar display for [Home Assistant][home-assistant] timers. Show the tim

<table><tr><td>

### ⬆️ Count-Up Functionality ⬆️

The Timer Bar Card can now also function as a count-up timer! This allows you to display the time elapsed since an event, such as how long your oven has been on. See [Count-Up Mode](#count-up-mode) for details.

</td></tr></table>

<table><tr><td>

### 🍄 Newly Added: Mushroom Styling 🍄

I've been really enjoying Paul Bottein's beautiful [Mushroom card collection](https://github.com/piitaya/lovelace-mushroom), so I've added new styles to make the card feel at home in your mushroom garden. Jump to [Configuring the Mushroom Style](#mushroom-style) for examples.
Expand Down Expand Up @@ -53,6 +61,50 @@ entities:

Most integrations require adding at least one or two additional lines of YAML configuration so the card knows the format of the timer. For more information on how these options work, see [Working with New Integrations](#-working-with-new-integrations).



### Count-Up Mode

This mode displays an increasing progress bar, showing the time elapsed since a specified event (usually the `last_changed` attribute of an entity).

To use count-up mode, you'll use the `max_value` option instead of `duration`.

**Basic Count-Up Example:**

```yaml
type: custom:timer-bar-card
entity: binary_sensor.oven_on # Entity that triggers the count-up
max_value: 2h # Bar fills completely after 2 hours
duration: null
active_state: "on" # Only active when the binary_sensor is "on"
```

**`max_value` Options:**

* **Specific Time:** Use a string like `"1h"`, `"30m"`, `"90s"` to set a fixed maximum value. The bar will fill completely when the elapsed time reaches this value.
* **`"auto"`:** The maximum value will automatically increase as the elapsed time approaches the current maximum. See [Automatic Max Value](#automatic-max-value-max_value-auto) for details.

#### Automatic Max Value (`max_value: auto`)

When `max_value` is set to `"auto"`, the card will dynamically increase the maximum value of the progress bar. This is useful for tracking events that might have an indefinite duration.

* **Initial Value:** The initial maximum value is determined by the `auto_increment` setting (defaults to 1 hour).
* **Increment:** The maximum value increases by the `auto_increment` amount when the elapsed time gets close to the current maximum (within 10% of the increment).
* **`auto_increment` Option:** Use this option to control both the initial max value and how much it increases each time.
```yaml
type: custom:timer-bar-card
entity: binary_sensor.oven_on
max_value: auto
auto_increment: 15m # Start at 15 minutes, increase by 15 minutes
active_state: # Active when the sensor is "on" or "active"
- "on"
- "active"

```
If `auto_increment` isn't set it will be 1h by default.

Most integrations require adding at least one or two additional lines of YAML configuration so the card knows the format of the timer. For more information on how these options work, see [Working with New Integrations](#-working-with-new-integrations).

### Integration Support Status

<pre><code><b>🌈 Did you configure the card for another integration? 🌈</b>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 101 additions & 51 deletions src/format-time.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,31 @@
/** secondsToDuration is adapted from the custom-card-helpers library,
which in turn was adapted from Home Assistant.

https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/seconds_to_duration.ts

I've added the configurable resolution.

EDIT: This has been turned into a general-purpose time formatting library.
*/

type Resolution = "seconds" | "minutes" | "automatic";

type Resolution = "seconds" | "minutes" | "automatic" | "hm" | "hms" | "mm:ss";

const leftPad = (num: number) => {
num = Math.abs(num);
return (num < 10 ? `0${num}` : num);
};


/** Rounds away from zero. */
const roundUp = (num: number) => num > 0 ? Math.ceil(num) : Math.floor(num);

/** Returns "minutes" if seconds>=1hr, otherwise minutes. */
function automaticRes(seconds: number) {
if (seconds >= 3600) return "minutes"
return "seconds"
if (seconds >= 3600) return "hms"
return "mm:ss"
}


export function formatFromResolution(seconds: number, resolution: Resolution): string {
switch (resolution === "automatic" ? automaticRes(seconds) : resolution) {
case "seconds": return "hms"
case "minutes": return "hm"
const resolved = resolution === "automatic" ? automaticRes(seconds) : resolution;
switch (resolved) {
case "seconds":
case "hms":
return "hms"
case "minutes":
case "mm:ss":
return "mm:ss"
case "hm": return "hm"
default: return "hms"
}
}

Expand All @@ -39,12 +34,14 @@ function joinWithColons(h: number, m: number, s: number): string {
if (m) return `${m}:${leftPad(s)}`;
return "" + s;
}

const hmsTime = (d: number) => {
const h = Math.trunc(d / 3600);
const m = Math.trunc((d % 3600) / 60);
const s = Math.trunc((d % 3600) % 60);
return joinWithColons(h, m, s);
}

const hmTime = (d: number) => {
let h = Math.trunc(d / 3600);
let m = Math.ceil((d % 3600) / 60); // Round up the minutes (#86)
Expand All @@ -56,44 +53,97 @@ const hmTime = (d: number) => {
return joinWithColons(0, h, m);
}

const mmssTime = (d: number) => {
const m = Math.trunc(d / 60);
const s = Math.trunc(d % 60);
return `${leftPad(m)}:${leftPad(s)}`;
}

/** Like Math.truncate(), but truncates `-.5` to `'-0'` */
function truncateWithSign(d: number): string {
const result = Math.trunc(d);
if (d < 0 && result === 0) return `-${-result}`;
return result.toString();
}

export default function formatTime(d: number, format: string) {
if (format == 'hms') return hmsTime(d)
if (format == 'hm') return hmTime(d)

// When rendering a single component, always round up to count consistent whole units.
if (format == 'd') return '' + Math.ceil(d / 24 / 3600)
if (format == 'h') return '' + Math.ceil(d / 3600)
if (format == 'm') return '' + Math.ceil(d / 60)
if (format == 's') return '' + Math.ceil(d)


// When rendering multiple components, round towards zero because the fraction should
// be represented by the next unit.
return format.replace(/%(\w+)/g, (match, S) => {
const sl = S.toLowerCase()
if (sl.startsWith('hms')) return hmsTime(d) + S.substring(3)
if (sl.startsWith('hm')) return hmTime(d) + S.substring(2)
// 1 lowercase letter: round up
if (S.startsWith('d')) return Math.ceil(d / 24 / 3600) + S.substring(1)
if (S.startsWith('h')) return Math.ceil(d / 3600) + S.substring(1)
if (S.startsWith('m')) return Math.ceil(d / 60) + S.substring(1)
if (S.startsWith('s')) return Math.ceil(d) + S.substring(1)
// 2 capital letter: pad + round down
if (S.startsWith('HH')) return leftPad(Math.trunc((d % (3600 * 24)) / 3600)) + S.substring(2)
if (S.startsWith('MM')) return leftPad(Math.trunc((d % 3600) / 60)) + S.substring(2)
if (S.startsWith('SS')) return leftPad(Math.trunc(d % 60)) + S.substring(2)
// 1 capital letter: round down, always include sign (for next component)
if (S.startsWith('D')) return truncateWithSign(d / 24 / 3600) + S.substring(1)
if (S.startsWith('H')) return truncateWithSign((d % (3600 * 24)) / 3600) + S.substring(1)
if (S.startsWith('M')) return truncateWithSign((d % 3600) / 60) + S.substring(1)
if (S.startsWith('S')) return truncateWithSign(d % 60) + S.substring(1)
return match
})
// Corrected function to format with days, months, and years
function formatWithLargeUnits(seconds: number) {
const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60;
const HOURS_IN_DAY = 24;
const DAYS_IN_MONTH = 30.44; // Average days in a month
const DAYS_IN_YEAR = 365.25; // Average days in a year (accounting for leap years)

let years = Math.floor(seconds / (SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY * DAYS_IN_YEAR));
seconds -= years * SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY * DAYS_IN_YEAR;

let months = Math.floor(seconds / (SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY * DAYS_IN_MONTH));
seconds -= months * SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY * DAYS_IN_MONTH;

let days = Math.floor(seconds / (SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY));
seconds -= days * SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY;

const h = Math.trunc(seconds / 3600);
seconds -= h * 3600
const m = Math.trunc(seconds / 60);
const s = Math.trunc(seconds % 60);

let result = "";
if (years > 0) result += `${years}y `;
if (months > 0) result += `${months}m `;
if (days > 0) result += `${days}d `;
result += `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`


return result.trim();
}

export default function formatTime(d: number, format: string) {
if (format === 'long') {
return formatWithLargeUnits(d);
}
if (format == 'hms') return hmsTime(d)
if (format == 'hm') return hmTime(d)
if (format == 'mm:ss') return mmssTime(d);

// When rendering a single component, always round up to count consistent whole units.
if (format == 'd') return '' + Math.ceil(d / 24 / 3600)
if (format == 'h') return '' + Math.ceil(d / 3600)
if (format == 'm') return '' + Math.ceil(d / 60)
if (format == 's') return '' + Math.ceil(d)


// When rendering multiple components, round towards zero because the fraction should
// be represented by the next unit.
return format.replace(/%(\w+)/g, (match, S) => {
const sl = S.toLowerCase()
if (sl.startsWith('hms')) return hmsTime(d) + S.substring(3)
if (sl.startsWith('hm')) return hmTime(d) + S.substring(2)
if (sl.startsWith('mm:ss')) return mmssTime(d) + S.substring(5);
// 1 lowercase letter: round up
// if (S.startsWith('d')) return Math.ceil(d / 24 / 3600) + S.substring(1)
if (S.startsWith('h')) return Math.ceil(d / 3600) + S.substring(1)
if (S.startsWith('m')) return Math.ceil(d / 60) + S.substring(1)
if (S.startsWith('s')) return Math.ceil(d) + S.substring(1)
// New format codes for years, months, days
if (S.startsWith('YYYY')) return leftPad(Math.floor(d / (3600 * 24 * 365.25))) + S.substring(4);
if (S.startsWith('YY')) return leftPad(Math.floor(d / (3600 * 24 * 365.25))) + S.substring(2); //maybe remove
if (S.startsWith('Y')) return Math.floor(d / (3600 * 24 * 365.25)) + S.substring(1);
if (S.startsWith('MONTHS')) return leftPad(Math.floor((d / (3600 * 24 * 30.44)) % 12)) + S.substring(6); // Corrected
if (S.startsWith('MON')) return leftPad(Math.floor((d / (3600 * 24 * 30.44)) % 12)) + S.substring(3); // Corrected
if (S.startsWith('MO')) return Math.floor((d / (3600 * 24 * 30.44)) % 12) + S.substring(2); // Corrected
if (S.startsWith('DD')) return leftPad(Math.floor((d % (3600 * 24 * 30.44)) / (3600 * 24))) + S.substring(2);
if (S.startsWith('D')) return Math.floor((d % (3600 * 24 * 30.44)) / (3600 * 24)) + S.substring(1);;
// 2 capital letter: pad + round down
if (S.startsWith('HH')) return leftPad(Math.trunc((d % (3600 * 24)) / 3600)) + S.substring(2)
if (S.startsWith('MM')) return leftPad(Math.trunc((d % 3600) / 60)) + S.substring(2)
if (S.startsWith('SS')) return leftPad(Math.trunc(d % 60)) + S.substring(2)
// 1 capital letter: round down, always include sign (for next component)
// if (S.startsWith('D')) return truncateWithSign(Math.floor(d / (3600 * 24))) + S.substring(1); // Corrected
if (S.startsWith('H')) return truncateWithSign(Math.trunc((d % (3600 * 24)) / 3600)) + S.substring(1)
if (S.startsWith('M')) return truncateWithSign(Math.trunc((d % 3600) / 60)) + S.substring(1)
if (S.startsWith('S')) return truncateWithSign(Math.trunc(d % 60)) + S.substring(1)

return match
})
}
Loading