Show availability on battery widget
This commit is contained in:
1 parent
ff1d2213dc
commit
63b53df6ff
3 files changed
+169
-63
No files matched your search
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
+79
-39
@@ -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,31 +108,24 @@ 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 {
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user