Show availability on battery widget

This commit is contained in:
SirBlob committed 2026-10-31 22:53:17 -04:00
1 parent ff1d2213dc
commit 63b53df6ff
3 files changed
+168 -62

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@
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.charging ? `Offline, recharging (${battery.level}%)` : `Available (${battery.level}%)`}
aria-expanded={open} aria-expanded={open}
onclick={() => (open = !open)} onclick={() => (open = !open)}
> >
+89 -23
View File
@@ -1,30 +1,36 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import { import {
batteryCapacityWh, availabilityLabel,
batteryHealth,
batteryIcon, batteryIcon,
batteryStatusLabel,
formatDuration, formatDuration,
isBatteryFull,
isBatteryLow, isBatteryLow,
powerRateLabel, moodLabel,
weekdayHoursLabel,
weekendHoursLabel,
type BatteryState type BatteryState
} from '#lib/desktop/battery'; } from '#lib/desktop/battery';
let { battery, onclose }: { battery: BatteryState; onclose: () => void } = $props(); let { battery, onclose }: { battery: BatteryState; onclose: () => void } = $props();
const isFull = $derived(isBatteryFull(battery)); let scheduleOpen = $state(false);
const schedule = [
{ label: 'Weekdays', value: weekdayHoursLabel },
{ label: 'Weekends', value: weekendHoursLabel }
];
const isLow = $derived(isBatteryLow(battery)); const isLow = $derived(isBatteryLow(battery));
const timeLeftLabel = $derived( const timeLeftLabel = $derived(
`${formatDuration(battery.minutesRemaining)} ${battery.charging ? 'until full' : 'left'}` battery.charging
? `back in ${formatDuration(battery.minutesRemaining)}`
: `${formatDuration(battery.minutesRemaining)} left`
); );
const details = $derived([ const details = $derived([
{ label: 'Battery size', value: `${batteryCapacityWh}Wh` }, { label: 'My time', value: battery.localTime },
{ label: 'Health', value: `${batteryHealth(battery)}%` }, { label: battery.charging ? 'Back online' : 'Offline at', value: battery.charging ? battery.backOnlineAt : battery.offlineAt }
{ label: 'Charge cycles', value: `${battery.chargeCycles}` },
{ label: 'Power', value: isFull ? '-' : powerRateLabel(battery) }
]); ]);
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
@@ -42,12 +48,10 @@
<Icon icon={batteryIcon(battery)} width="30" /> <Icon icon={batteryIcon(battery)} width="30" />
</span> </span>
<div class="summary-text"> <div class="summary-text">
<span class="title">Battery</span> <span class="title">Availability</span>
<span class="status"> <span class="status">
{batteryStatusLabel(battery)} {availabilityLabel(battery)}
{#if !isFull} <span class="time-left">· {timeLeftLabel}</span>
<span class="time-left">· {timeLeftLabel}</span>
{/if}
</span> </span>
</div> </div>
<span class="level">{battery.level}%</span> <span class="level">{battery.level}%</span>
@@ -57,6 +61,8 @@
<div class="level-fill" style:width="{battery.level}%"></div> <div class="level-fill" style:width="{battery.level}%"></div>
</div> </div>
<p class="mood">{moodLabel(battery)}</p>
<dl class="details"> <dl class="details">
{#each details as detail (detail.label)} {#each details as detail (detail.label)}
<div class="detail-row"> <div class="detail-row">
@@ -65,6 +71,25 @@
</div> </div>
{/each} {/each}
</dl> </dl>
<section class="schedule">
<button class="schedule-toggle" aria-expanded={scheduleOpen} onclick={() => (scheduleOpen = !scheduleOpen)}>
<Icon icon="mdi:calendar-clock" width="16" />
<span>Schedule</span>
<span class="chevron" class:open={scheduleOpen}><Icon icon="mdi:chevron-down" width="16" /></span>
</button>
{#if scheduleOpen}
<dl class="schedule-list" transition:slide={{ duration: 180 }}>
{#each schedule as day (day.label)}
<div class="detail-row">
<dt>{day.label}</dt>
<dd>{day.value}</dd>
</div>
{/each}
</dl>
{/if}
</section>
</div> </div>
</div> </div>
@@ -151,12 +176,19 @@
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.mood {
margin: 0.6rem 1rem 0;
color: var(--state-color);
font-size: 0.8rem;
font-style: italic;
}
.details { .details {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr;
gap: 0.5rem 1.25rem; gap: 0.45rem;
margin: 0; margin: 0;
padding: 1rem; padding: 0.9rem 1rem 1rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
@@ -181,6 +213,44 @@
letter-spacing: normal; letter-spacing: normal;
} }
.schedule {
border-top: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
}
.schedule-toggle {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 0.6rem 1rem;
background: none;
border: none;
color: var(--foreground);
font-size: 0.8rem;
}
.schedule-toggle:hover {
color: var(--accent);
}
.chevron {
display: grid;
margin-left: auto;
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.schedule-list {
display: grid;
gap: 0.45rem;
margin: 0;
padding: 0 1rem 0.9rem;
font-size: 0.8rem;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.popover { .popover {
position: fixed; position: fixed;
@@ -189,9 +259,5 @@
left: 8px; left: 8px;
width: auto; width: auto;
} }
.details {
grid-template-columns: 1fr;
}
} }
</style> </style>
+78 -38
View File
@@ -1,53 +1,100 @@
const minute = 60 * 1000; const timeZone = 'America/New_York';
const hour = 60 * minute; const timeZoneLabel = 'ET';
const dayDuration = 24 * hour; const minutesPerDay = 24 * 60;
const dischargeDuration = 5 * hour; const lowestLevel = 5;
const chargeDuration = 1 * hour; const weekendDays = new Set([0, 6]);
const cycleDuration = dischargeDuration + chargeDuration;
const cycleCountStart = Date.UTC(2025, 10, 21);
export const batteryCapacityWh = 42; interface ActiveHours {
startMinute: number;
endMinute: number;
}
const weekdayHours: ActiveHours = { startMinute: 9 * 60, endMinute: 19 * 60 };
const weekendHours: ActiveHours = { startMinute: 11 * 60, endMinute: 20 * 60 };
export interface BatteryState { export interface BatteryState {
level: number; level: number;
charging: boolean; charging: boolean;
minutesRemaining: number; minutesRemaining: number;
chargeCycles: number; localTime: string;
offlineAt: string;
backOnlineAt: string;
} }
function easternWallClockMs(date: Date): number { function easternClock(date: Date): { minuteOfDay: number; dayOfWeek: number } {
const parts = new Intl.DateTimeFormat('en-US', { const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York', timeZone,
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit',
hourCycle: 'h23' hourCycle: 'h23'
}).formatToParts(date); }).formatToParts(date);
const part = (type: string) => Number(parts.find((item) => item.type === type)?.value); const part = (type: string) => Number(parts.find((item) => item.type === type)?.value);
return Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second')); const wallClockMs = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'));
return {
minuteOfDay: part('hour') * 60 + part('minute'),
dayOfWeek: new Date(wallClockMs).getUTCDay()
};
}
function hoursForDay(dayOfWeek: number): ActiveHours {
return weekendDays.has((dayOfWeek + 7) % 7) ? weekendHours : weekdayHours;
}
function formatClockTime(minuteOfDay: number): string {
const hours = Math.floor(minuteOfDay / 60);
const minutes = minuteOfDay % 60;
const period = hours < 12 ? 'AM' : 'PM';
const displayHour = hours % 12 || 12;
return minutes === 0 ? `${displayHour} ${period}` : `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
}
function formatHours({ startMinute, endMinute }: ActiveHours): string {
return `${formatClockTime(startMinute)} – ${formatClockTime(endMinute)} ${timeZoneLabel}`;
}
export const weekdayHoursLabel = formatHours(weekdayHours);
export const weekendHoursLabel = formatHours(weekendHours);
function chargeLevel(minutesCharging: number, chargeDuration: number): number {
return Math.round(lowestLevel + (minutesCharging / chargeDuration) * (100 - lowestLevel));
} }
export function batteryAt(date: Date): BatteryState { export function batteryAt(date: Date): BatteryState {
const wallClock = easternWallClockMs(date); const { minuteOfDay, dayOfWeek } = easternClock(date);
const position = wallClock % cycleDuration; const today = hoursForDay(dayOfWeek);
const chargeCycles = Math.floor((wallClock - cycleCountStart) / dayDuration); const yesterday = hoursForDay(dayOfWeek - 1);
const tomorrow = hoursForDay(dayOfWeek + 1);
const localTime = `${formatClockTime(minuteOfDay)} ${timeZoneLabel}`;
const offlineAt = `${formatClockTime(today.endMinute)} ${timeZoneLabel}`;
if (position < dischargeDuration) { if (minuteOfDay >= today.startMinute && minuteOfDay < today.endMinute) {
const activeDuration = today.endMinute - today.startMinute;
const minutesActive = minuteOfDay - today.startMinute;
return { return {
level: Math.round(100 - (position / dischargeDuration) * 100), level: Math.round(100 - (minutesActive / activeDuration) * (100 - lowestLevel)),
charging: false, charging: false,
minutesRemaining: Math.round((dischargeDuration - position) / minute), minutesRemaining: today.endMinute - minuteOfDay,
chargeCycles localTime,
offlineAt,
backOnlineAt: `${formatClockTime(tomorrow.startMinute)} ${timeZoneLabel}`
}; };
} }
const beforeToday = minuteOfDay < today.startMinute;
const chargeStart = beforeToday ? yesterday.endMinute - minutesPerDay : today.endMinute;
const chargeEnd = beforeToday ? today.startMinute : minutesPerDay + tomorrow.startMinute;
const nextStart = beforeToday ? today.startMinute : tomorrow.startMinute;
return { return {
level: Math.round(((position - dischargeDuration) / chargeDuration) * 100), level: chargeLevel(minuteOfDay - chargeStart, chargeEnd - chargeStart),
charging: true, charging: true,
minutesRemaining: Math.round((cycleDuration - position) / minute), minutesRemaining: chargeEnd - minuteOfDay,
chargeCycles localTime,
offlineAt,
backOnlineAt: `${formatClockTime(nextStart)} ${timeZoneLabel}`
}; };
} }
@@ -61,27 +108,20 @@ export function batteryIcon({ level, charging }: BatteryState): string {
return 'mdi:battery-alert'; return 'mdi:battery-alert';
} }
export function isBatteryFull({ level }: BatteryState): boolean {
return level >= 100;
}
export function isBatteryLow({ level, charging }: BatteryState): boolean { export function isBatteryLow({ level, charging }: BatteryState): boolean {
return level < 20 && !charging; return level < 20 && !charging;
} }
export function batteryStatusLabel(battery: BatteryState): string { export function availabilityLabel({ charging }: BatteryState): string {
if (isBatteryFull(battery)) return 'Fully charged'; return charging ? 'Offline · recharging' : 'Available';
return battery.charging ? 'Charging' : 'Discharging';
} }
export function batteryHealth({ chargeCycles }: BatteryState): number { export function moodLabel({ level, charging }: BatteryState): string {
return Math.max(80, 100 - Math.round(chargeCycles / 40)); if (charging) return level > 70 ? 'Almost rebooted, coffee is brewing' : 'Asleep, recharging the brain cells';
} if (level > 75) return 'Fully caffeinated and ready to build';
if (level > 45) return 'Running smoothly';
export function powerRateLabel({ charging }: BatteryState): string { if (level > 20) return 'Could use another coffee';
const durationInHours = (charging ? chargeDuration : dischargeDuration) / hour; return 'Running on fumes, wrapping up for the day';
const watts = (batteryCapacityWh / durationInHours).toFixed(1);
return charging ? `+${watts}W` : `-${watts}W`;
} }
export function formatDuration(totalMinutes: number): string { export function formatDuration(totalMinutes: number): string {