Add battery details popover
This commit is contained in:
1 parent
0c53bad968
commit
950a38aad8
3 files changed
+275
-8
No files matched your search
@@ -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>
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user