Add battery details popover

This commit is contained in:
SirBlob committed 2026-10-31 18:41:16 -04:00
1 parent 0c53bad968
commit 950a38aad8
3 files changed
+275 -8

No files matched your search

+26 -4
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import { batteryAt, batteryIcon, type BatteryState } from '#lib/desktop/battery'; import { batteryAt, batteryIcon, isBatteryLow, type BatteryState } from '#lib/desktop/battery';
import BatteryPopover from './BatteryPopover.svelte';
let battery = $state<BatteryState | null>(null); let battery = $state<BatteryState | null>(null);
let open = $state(false);
const isLow = $derived(!!battery && battery.level < 20 && !battery.charging); const isLow = $derived(!!battery && isBatteryLow(battery));
onMount(() => { onMount(() => {
battery = batteryAt(new Date()); battery = batteryAt(new Date());
@@ -15,23 +17,43 @@
</script> </script>
{#if battery} {#if battery}
<span <div class="battery-widget">
<button
class="battery" class="battery"
class:low={isLow} class:low={isLow}
class:charging={battery.charging} class:charging={battery.charging}
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}" title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
aria-expanded={open}
onclick={() => (open = !open)}
> >
<Icon icon={batteryIcon(battery)} width="18" /> <Icon icon={batteryIcon(battery)} width="18" />
<span class="level">{battery.level}%</span> <span class="level">{battery.level}%</span>
</span> </button>
{#if open}
<BatteryPopover {battery} onclose={() => (open = false)} />
{/if}
</div>
{/if} {/if}
<style> <style>
.battery-widget {
position: relative;
}
.battery { .battery {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
height: var(--bar-height);
padding: 0 0.4rem; padding: 0 0.4rem;
background: none;
border: none;
color: var(--foreground);
}
.battery:hover {
color: var(--accent);
} }
.charging { .charging {
@@ -0,0 +1,197 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import {
batteryCapacityWh,
batteryHealth,
batteryIcon,
batteryStatusLabel,
formatDuration,
isBatteryFull,
isBatteryLow,
powerRateLabel,
type BatteryState
} from '#lib/desktop/battery';
let { battery, onclose }: { battery: BatteryState; onclose: () => void } = $props();
const isFull = $derived(isBatteryFull(battery));
const isLow = $derived(isBatteryLow(battery));
const timeLeftLabel = $derived(
`${formatDuration(battery.minutesRemaining)} ${battery.charging ? 'until full' : 'left'}`
);
const details = $derived([
{ label: 'Battery size', value: `${batteryCapacityWh}Wh` },
{ label: 'Health', value: `${batteryHealth(battery)}%` },
{ label: 'Charge cycles', value: `${battery.chargeCycles}` },
{ label: 'Power', value: isFull ? '-' : powerRateLabel(battery) }
]);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Battery details">
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
<header class="summary">
<span class="battery-icon">
<Icon icon={batteryIcon(battery)} width="30" />
</span>
<div class="summary-text">
<span class="title">Battery</span>
<span class="status">
{batteryStatusLabel(battery)}
{#if !isFull}
<span class="time-left">· {timeLeftLabel}</span>
{/if}
</span>
</div>
<span class="level">{battery.level}%</span>
</header>
<div class="level-track">
<div class="level-fill" style:width="{battery.level}%"></div>
</div>
<dl class="details">
{#each details as detail (detail.label)}
<div class="detail-row">
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
{/each}
</dl>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: none;
border: none;
cursor: default;
}
.popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
width: 340px;
padding: var(--border-size);
background: var(--border-active);
animation: window-pop-in 0.15s ease-out;
}
.popover-surface {
--state-color: var(--accent);
background: var(--background);
}
.popover-surface.charging {
--state-color: var(--green);
}
.popover-surface.low {
--state-color: var(--red);
}
.summary {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
}
.battery-icon {
display: grid;
place-items: center;
color: var(--state-color);
}
.summary-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.title {
font-size: 1.05rem;
font-weight: 700;
}
.status {
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.level {
font-size: 1.9rem;
font-weight: 700;
}
.level-track {
height: 8px;
margin: 0 1rem;
background: var(--lighter-background);
}
.level-fill {
height: 100%;
background: var(--state-color);
transition: width 0.3s ease;
}
.details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1.25rem;
margin: 0;
padding: 1rem;
font-size: 0.8rem;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
white-space: nowrap;
}
.detail-row dt {
color: var(--muted);
}
.detail-row dd {
margin: 0;
}
.time-left {
color: var(--foreground);
text-transform: none;
letter-spacing: normal;
}
@media (max-width: 640px) {
.popover {
position: fixed;
top: calc(var(--bar-height) + 6px);
right: 8px;
left: 8px;
width: auto;
}
.details {
grid-template-columns: 1fr;
}
}
</style>
+52 -4
View File
@@ -1,11 +1,18 @@
const hour = 3600 * 1000; const minute = 60 * 1000;
const hour = 60 * minute;
const dayDuration = 24 * hour;
const dischargeDuration = 5 * hour; const dischargeDuration = 5 * hour;
const chargeDuration = 1 * hour; const chargeDuration = 1 * hour;
const cycleDuration = dischargeDuration + chargeDuration; const cycleDuration = dischargeDuration + chargeDuration;
const cycleCountStart = Date.UTC(2025, 10, 21);
export const batteryCapacityWh = 42;
export interface BatteryState { export interface BatteryState {
level: number; level: number;
charging: boolean; charging: boolean;
minutesRemaining: number;
chargeCycles: number;
} }
function easternWallClockMs(date: Date): number { function easternWallClockMs(date: Date): number {
@@ -24,12 +31,24 @@ function easternWallClockMs(date: Date): number {
} }
export function batteryAt(date: Date): BatteryState { export function batteryAt(date: Date): BatteryState {
const position = easternWallClockMs(date) % cycleDuration; const wallClock = easternWallClockMs(date);
const position = wallClock % cycleDuration;
const chargeCycles = Math.floor((wallClock - cycleCountStart) / dayDuration);
if (position < dischargeDuration) { if (position < dischargeDuration) {
return { level: Math.round(100 - (position / dischargeDuration) * 100), charging: false }; return {
level: Math.round(100 - (position / dischargeDuration) * 100),
charging: false,
minutesRemaining: Math.round((dischargeDuration - position) / minute),
chargeCycles
};
} }
return { level: Math.round(((position - dischargeDuration) / chargeDuration) * 100), charging: true }; return {
level: Math.round(((position - dischargeDuration) / chargeDuration) * 100),
charging: true,
minutesRemaining: Math.round((cycleDuration - position) / minute),
chargeCycles
};
} }
export function batteryIcon({ level, charging }: BatteryState): string { export function batteryIcon({ level, charging }: BatteryState): string {
@@ -41,3 +60,32 @@ export function batteryIcon({ level, charging }: BatteryState): string {
if (level > 10) return 'mdi:battery-20'; if (level > 10) return 'mdi:battery-20';
return 'mdi:battery-alert'; return 'mdi:battery-alert';
} }
export function isBatteryFull({ level }: BatteryState): boolean {
return level >= 100;
}
export function isBatteryLow({ level, charging }: BatteryState): boolean {
return level < 20 && !charging;
}
export function batteryStatusLabel(battery: BatteryState): string {
if (isBatteryFull(battery)) return 'Fully charged';
return battery.charging ? 'Charging' : 'Discharging';
}
export function batteryHealth({ chargeCycles }: BatteryState): number {
return Math.max(80, 100 - Math.round(chargeCycles / 40));
}
export function powerRateLabel({ charging }: BatteryState): string {
const durationInHours = (charging ? chargeDuration : dischargeDuration) / hour;
const watts = (batteryCapacityWh / durationInHours).toFixed(1);
return charging ? `+${watts}W` : `-${watts}W`;
}
export function formatDuration(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}