daycards strip

This commit is contained in:
Vincent van der Wal
2026-07-25 16:14:13 +02:00
parent 61d84e4450
commit cc1589b017
7 changed files with 401 additions and 36 deletions
+3 -2
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { PREMIUM_PERKS, PREMIUM_PRICE, SIGNUP_URL } from './config';
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
import { isPremium, premiumState, refreshPremium } from './premium';
interface Props {
@@ -14,6 +14,7 @@
let { feature = 'This page', children }: Props = $props();
let unlockOpen = $state(false);
const price = getPremiumPrice();
// Re-verify the stored key whenever the gate mounts.
onMount(refreshPremium);
@@ -53,7 +54,7 @@
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
Support this open-source weather project and unlock the extras from {PREMIUM_PRICE}.
Support this open-source weather project and unlock the extras from {price}.
</p>
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
+4 -2
View File
@@ -6,7 +6,7 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { PREMIUM_PRICE, SIGNUP_URL } from './config';
import { SIGNUP_URL, getPremiumPrice } from './config';
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
interface Props {
@@ -15,6 +15,8 @@
let { open = $bindable(false) }: Props = $props();
const price = getPremiumPrice();
// Prefill with the stored key so an existing subscriber sees their key.
let keyInput = $state('');
let submitting = $state(false);
@@ -129,7 +131,7 @@
rel="noopener"
class="font-semibold text-primary underline-offset-2 hover:underline"
>
Subscribe from {PREMIUM_PRICE}
Subscribe from {price}
</a>
</p>
{/if}
+40 -2
View File
@@ -19,8 +19,46 @@ export const PAYWALL_API_BASE = stripTrailingSlash(
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
/** Display price, shown on the paywall panel. */
export const PREMIUM_PRICE = env.VITE_PREMIUM_PRICE ?? '€3 / month';
// ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
// from their location. Mirror that here so the paywall copy matches. Detection
// is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
const US_ZONES =
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
export function detectCurrency(): PremiumCurrency {
if (typeof Intl === 'undefined') return 'EUR';
let tz = '';
try {
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
/* ignore */
}
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
let region = '';
try {
if (typeof navigator !== 'undefined') {
region = new Intl.Locale(navigator.language).maximize().region || '';
}
} catch {
/* ignore */
}
if (region === 'CH') return 'CHF';
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
return 'EUR';
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
export function getPremiumPrice(): string {
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
const currency = detectCurrency();
const symbol = CURRENCY_SYMBOL[currency];
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
}
/** Short, human list of what premium unlocks (shown on the locked panel). */
export const PREMIUM_PERKS = [
+46 -23
View File
@@ -18,6 +18,7 @@
import { defaultParameters } from '../../options';
import DailyCards from './DailyCards.svelte';
import DailyStripSticky from './DailyStripSticky.svelte';
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
@@ -199,33 +200,55 @@
</div>
{/if}
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{#if fetchedHourly && fetchedDaily}
<HourlyTable
data={fetchedHourly}
<!-- Desktop: the full day cards -->
<div class="hidden md:block">
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div
transition:fade={{ duration: 200 }}
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if}
</div>
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
the strip stays stuck (collapsing as it goes) while scrolling the table
and then releases exactly at the table's bottom, freeing the meteograms.
The strip itself is hidden on md+ (the desktop cards above take over). -->
<div>
{#if fetchedDaily}
<DailyStripSticky
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{/if}
{#if fetchedHourly && fetchedDaily}
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div
transition:fade={{ duration: 200 }}
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if}
</div>
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
@@ -0,0 +1,289 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits } from './types';
interface Props {
daily: FetchedDaily | null;
selectedDay: Date;
units: WeatherUnits;
onSelectDay: (date: Date, index: number) => void;
canExtend?: boolean;
onExtend?: () => void;
canExtendPast?: boolean;
onExtendPast?: () => void;
}
let {
daily,
selectedDay,
units,
onSelectDay,
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
}: Props = $props();
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
// (which reflows the table BELOW it) never feeds back into the measurement.
let progress = $state(0);
let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
// Resolved in onMount (client only) so this never touches `window` during the
// prerender of city pages.
let scrollParent: HTMLElement | Window | null = $state(null);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const lerp = (a: number, b: number) => a + (b - a) * progress;
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
let node = el?.parentElement ?? null;
while (node) {
const oy = getComputedStyle(node).overflowY;
if (oy === 'auto' || oy === 'scroll') return node;
node = node.parentElement;
}
return window;
}
// Distance (px) over which the collapse plays out once the strip sticks.
const RANGE = 120;
let ticking = false;
function measure() {
ticking = false;
if (!sentinelEl || !scrollParent) return;
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
const top = sentinelEl.getBoundingClientRect().top - topRef;
progress = clamp(-top / RANGE);
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(measure);
}
onMount(() => {
scrollParent = findScrollParent(sentinelEl ?? null);
const target: EventTarget = scrollParent;
target.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
measure();
return () => {
target.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
// Re-measure once data (and therefore the strip height) changes.
$effect(() => {
void daily;
tick().then(measure);
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
// scrolling left), with the first day flush to the content edge — same as the
// desktop cards. Runs once per dataset.
let scrolledForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
scrolledForRef = d;
const scroll = stripScrollEl;
const wrap = daysWrapEl;
requestAnimationFrame(() => {
// 12px = the strip's px-3 left padding, so the first day lands on the edge
scroll.scrollLeft +=
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
});
});
// ─── Derived sizing ─────────────────────────────────────────────────────────
const CELL_W = 64;
// Full height fits the whole stack (weekday, label, icon, temps, precip+wind)
// without clipping; the horizontal scroll container also clips vertically, so
// the content must fit inside the cell.
let cellH = $derived(lerp(134, 64));
let iconSize = $derived(lerp(40, 20));
// Top padding eases from pt-1.5 (6px) when full to pt-0.5 (2px) when compact.
let padTop = $derived(lerp(6, 1));
// The secondary rows collapse (height + opacity together) faster than the
// overall shrink, so the cell reaches a clean square before it stops shrinking.
let detailFactor = $derived(clamp(1 - progress * 1.6));
let relFactor = $derived(clamp(1 - progress * 2));
// Bar background/divider/shadow fade in promptly once it starts collapsing.
let chromeOpacity = $derived(clamp(progress / 0.35));
</script>
<!-- progress sentinel: 0-height marker just above the sticky strip -->
<div bind:this={sentinelEl} aria-hidden="true"></div>
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--chrome:{chromeOpacity}">
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
style="width:{CELL_W}px;height:{cellH}px"
onclick={onExtendPast}
aria-label="Load recent past days"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
</svg>
<span class="text-[9px] leading-tight font-semibold">Past</span>
</button>
{/if}
<div class="flex gap-1.5" bind:this={daysWrapEl}>
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
type="button"
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}"
style="width:{CELL_W}px;height:{cellH}px;padding-top:{padTop}px"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<span
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="overflow-hidden text-[9px] leading-none text-muted-foreground"
style="opacity:{relFactor};max-height:{relFactor * 14}px"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="relative">
<svg class="fill-foreground" width="{iconSize}px" height="{iconSize}px">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<!-- night companion icon, present in the full view, fades as it collapses -->
<svg
class="absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
width="{iconSize * 0.44}px"
height="{iconSize * 0.44}px"
style="opacity:{relFactor}"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<div class="flex items-baseline gap-1 leading-none">
<span
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
<div
class="flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
style="opacity:{detailFactor};max-height:{detailFactor * 42}px"
>
<span class="inline-flex items-center gap-1">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<span class="inline-flex items-center gap-1">
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{windMax?.toFixed(0) ?? '-'}
</span>
</div>
</button>
{/if}
{/each}
{#if canExtend && onExtend}
<button
type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
style="width:{CELL_W}px;height:{cellH}px"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
</svg>
<span class="text-[9px] leading-tight font-semibold">15d</span>
</button>
{/if}
</div>
{/if}
</div>
</div>
<style>
/* The bar background/divider/shadow fade in only once the strip has collapsed
into its compact form, so at the top of the page it reads as plain cards. */
.daystrip {
background: color-mix(
in oklab,
var(--color-background) calc(var(--chrome) * 100%),
transparent
);
backdrop-filter: blur(calc(var(--chrome) * 6px));
border-bottom: 1px solid
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
box-shadow: 0 6px 12px -8px rgba(0, 0, 0, calc(var(--chrome) * 0.35));
}
.strip-cell,
.strip-side {
transition:
border-color 0.15s,
background-color 0.15s;
}
.daystrip :global(.overflow-x-auto) {
scrollbar-width: none;
}
</style>
@@ -42,13 +42,19 @@
>
<Select.Trigger
aria-label="{label} selection"
class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
>
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
>
<!-- layered-globe icon: weather model -->
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<svg
class="size-4.5 sm:size-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
@@ -60,9 +66,14 @@
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{label}
</span>
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm"
>{modelLabel}</span
>
{#if modelMeta}
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
<!-- the meta line is dropped on mobile to keep the trigger compact -->
<span
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
>
{modelMeta}
</span>
{/if}
@@ -496,8 +496,9 @@ export function buildPanelDef(
unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// pressure sits far from zero, so its axis is derived from the data
zeroBaseLeft: leftKind !== 'pressure',
// temperature and pressure sit far from zero, so their axis is derived from
// the data range (a forced 0 baseline just wastes vertical space)
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
yMinRight: rightKind && rightZero ? 0 : undefined,
yMaxRight: rightKind === 'percent' ? 100 : undefined,
hasPictograms: allDefs.some((d) => d.pictograms),