Files
dickloads/src/routes/+page.svelte
Falkan 8ec935d238 fix: alpha sort by labelPlural primary, symbol secondary
Handles ties like 'Barrel (US fluid)' vs 'Barrel (US dry)'
2026-03-18 21:13:17 -04:00

252 lines
9.3 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { convert, convertYolo, canConvert } from '$lib/convert';
import { FRACTIONAL_DIGITS } from '$lib/config';
import { formatBig } from '$lib/format';
import Big from 'big.js';
import type { Unit, Group, ResultItem } from '$lib/types';
import ConverterCard from '../components/ConverterCard.svelte';
/**
* Converter page — loads units dynamically from server props.
* Supports same-group and cross-group (YOLO) conversions.
*/
let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string; yoloVisibility: 'auto' | 'never' } } = $props();
let units: Unit[] = $derived(data.units);
let groups: Group[] = $derived(data.groups);
let yoloLabel: string = $derived(data.yoloLabel);
let yoloDescription: string = $derived(data.yoloDescription);
let yoloVisibility: 'auto' | 'never' = $derived(data.yoloVisibility);
let showYoloToggle: boolean = $derived(
yoloVisibility !== 'never' && groups.length > 1
);
// ── Reactive state ──────────────────────────────────────────────────────────
let inputValue = $state<number>(1);
let fromUnitId = $state<string>('');
let highlightedUnitId = $state<string | null>(null);
let yoloMode = $state<boolean>(false);
// Initialize fromUnitId to first unit once data arrives
$effect(() => {
if (fromUnitId === '' && units.length > 0) {
fromUnitId = units[0].id;
}
});
// ── Load YOLO preference from localStorage ──────────────────────────────────
// Single effect: read on mount, then write on every change.
// The `initialized` guard prevents the write from firing before the read on mount.
let yoloInitialized = $state(false);
$effect(() => {
if (!browser) return;
if (!yoloInitialized) {
// First run: read stored value
const stored = localStorage.getItem('yolo-mode');
if (stored === 'true') yoloMode = true;
yoloInitialized = true;
return;
}
// Subsequent runs: persist change
localStorage.setItem('yolo-mode', String(yoloMode));
});
// ── Initialize from URL params on first client-side load ───────────────────
if (browser) {
const params = page.url.searchParams;
const pValue = params.get('value');
const pFrom = params.get('from');
const pHighlight = params.get('highlight');
if (pValue !== null) {
const n = parseFloat(pValue);
if (!isNaN(n)) inputValue = n;
}
if (pFrom !== null && data.units.some((u) => u.id === pFrom)) {
fromUnitId = pFrom;
}
if (pHighlight !== null && data.units.some((u) => u.id === pHighlight)) {
highlightedUnitId = pHighlight;
}
}
// ── Derived: results for ALL units ─────────────────────────────────────────
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
let results: ResultItem[] = $derived.by(() => {
if (!fromUnit) return [];
const bigInput = Big(inputValue); // hoist — one allocation per recompute, not per unit
return units.map((u) => {
const sameGroup = canConvert(fromUnit, u);
if (sameGroup) {
return { unit: u, convertedValue: convert(bigInput, fromUnit, u), isYolo: false };
} else if (yoloMode) {
return { unit: u, convertedValue: convertYolo(bigInput, fromUnit, u, groups), isYolo: true };
} else {
return { unit: u, convertedValue: null, isYolo: false };
}
});
});
// ── Derived: highlighted result ─────────────────────────────────────────────
let highlightedResult = $derived(
highlightedUnitId !== null
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
: null
);
// ── URL sync ────────────────────────────────────────────────────────────────
// Use history.replaceState directly — goto() triggers a SvelteKit navigation
// which re-renders the page and steals focus from the input on every keystroke.
$effect(() => {
if (!browser) return;
const params = new URLSearchParams();
params.set('value', String(inputValue));
params.set('from', fromUnitId);
if (highlightedUnitId !== null) {
params.set('highlight', highlightedUnitId);
}
const next = params.toString();
if (next !== window.location.search.slice(1)) {
history.replaceState(history.state, '', `?${next}`);
}
});
// ── Event handlers ──────────────────────────────────────────────────────────
function handleUnitChange(id: string) {
fromUnitId = id;
if (highlightedUnitId === id) {
highlightedUnitId = null;
}
}
function handleHighlight(id: string) {
highlightedUnitId = id === highlightedUnitId ? null : id;
}
function handleSetInput(id: string) {
fromUnitId = id;
}
function handleCtrlClick(id: string, convertedValue: Big) {
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
fromUnitId = id;
}
// ── Derived: results per group for display — single O(n) pass ──────────────
let orderedResults: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = $derived.by(() => {
// Build a Map of groupId → items in a single pass over results
const byGroup = new Map<string | null, ResultItem[]>();
for (const r of results) {
const key = r.unit.group ?? null;
let bucket = byGroup.get(key);
if (!bucket) { bucket = []; byGroup.set(key, bucket); }
bucket.push(r);
}
// Emit sections in group-definition order, then orphaned at end
const sections: { groupLabel: string | null; alwaysShowLabel: boolean; items: ResultItem[] }[] = [];
for (const group of groups) {
const items = byGroup.get(group.id);
if (items?.length) {
const sorted = group.sortOrder === 'alpha'
? [...items].sort((a, b) => {
const labelCmp = a.unit.labelPlural.localeCompare(b.unit.labelPlural);
return labelCmp !== 0 ? labelCmp : a.unit.symbol.localeCompare(b.unit.symbol);
})
: items;
sections.push({ groupLabel: group.label, alwaysShowLabel: group.alwaysShowLabel ?? false, items: sorted });
}
}
const orphaned = byGroup.get(null);
if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', alwaysShowLabel: false, items: orphaned });
return sections;
});
</script>
<svelte:head>
<title>Humor Units — Converter</title>
</svelte:head>
<div class="highlighted-result" class:has-highlight={highlightedResult !== null} aria-live="polite">
{#if highlightedResult !== null && highlightedResult.convertedValue !== null}
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
<div class="highlighted-equation">
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit?.labelPlural}</span>
<span class="highlighted-eq-sep">=</span>
<span class="highlighted-eq-to">
{#if highlightedResult.isYolo}<span class="yolo-marker">~</span>{/if}{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}
</span>
</div>
{:else if highlightedResult !== null && highlightedResult.convertedValue === null}
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
<span class="highlighted-placeholder">N/A — enable YOLO mode for cross-group conversions</span>
{:else}
<span class="highlighted-placeholder">Click a result to highlight it</span>
{/if}
</div>
<div class="yolo-toggle-row" class:yolo-hidden={!showYoloToggle}>
{#if showYoloToggle}
<label class="yolo-label">
<input type="checkbox" bind:checked={yoloMode} role="switch" />
<span class="yolo-label-text">{yoloLabel}</span>
{#if yoloDescription}
<small class="yolo-desc">{yoloDescription}</small>
{/if}
</label>
{/if}
</div>
<ConverterCard
{units}
{groups}
{inputValue}
{fromUnitId}
{highlightedUnitId}
{results}
{orderedResults}
oninputchange={(v) => (inputValue = v)}
onunitchange={handleUnitChange}
onhighlight={handleHighlight}
onsetinput={handleSetInput}
onctrlclick={handleCtrlClick}
/>
<style>
.yolo-toggle-row {
margin-bottom: 1rem;
min-height: 0;
}
.yolo-toggle-row.yolo-hidden {
margin-bottom: 0;
}
.yolo-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.yolo-label-text {
font-weight: 500;
}
.yolo-desc {
color: var(--pico-muted-color);
white-space: nowrap;
}
.yolo-marker {
color: var(--pico-muted-color);
font-weight: bold;
margin-right: 1px;
}
</style>