feat: add Night Shift layer (boot intro, ops strip, headlight, chapter numerals, film grain)
This commit is contained in:
@@ -10,6 +10,7 @@ import './styles/evolution.css';
|
|||||||
import './styles/chromatic.css';
|
import './styles/chromatic.css';
|
||||||
import './styles/refinements.css';
|
import './styles/refinements.css';
|
||||||
import './styles/title-navigation.css';
|
import './styles/title-navigation.css';
|
||||||
|
import './styles/nightshift.css';
|
||||||
import './styles/print.css';
|
import './styles/print.css';
|
||||||
|
|
||||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||||
import ContactDestination from './ContactDestination.vue';
|
import ContactDestination from './ContactDestination.vue';
|
||||||
import DriveControls from './DriveControls.vue';
|
import DriveControls from './DriveControls.vue';
|
||||||
|
import BootSequence from './BootSequence.vue';
|
||||||
import ExperienceTimeline from './ExperienceTimeline.vue';
|
import ExperienceTimeline from './ExperienceTimeline.vue';
|
||||||
import HeroJourney from './HeroJourney.vue';
|
import HeroJourney from './HeroJourney.vue';
|
||||||
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
||||||
import JourneyHud from './JourneyHud.vue';
|
import JourneyHud from './JourneyHud.vue';
|
||||||
|
import OperationsStrip from './OperationsStrip.vue';
|
||||||
import PortfolioSection from './PortfolioSection.vue';
|
import PortfolioSection from './PortfolioSection.vue';
|
||||||
import ProjectCard from './ProjectCard.vue';
|
import ProjectCard from './ProjectCard.vue';
|
||||||
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
||||||
@@ -16,6 +18,7 @@ import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
|||||||
import { useAudioEngine } from '@/composables/useAudioEngine';
|
import { useAudioEngine } from '@/composables/useAudioEngine';
|
||||||
import { useAudioIntent } from '@/composables/useAudioIntent';
|
import { useAudioIntent } from '@/composables/useAudioIntent';
|
||||||
import { useJourneySections } from '@/composables/useJourneySections';
|
import { useJourneySections } from '@/composables/useJourneySections';
|
||||||
|
import { useHeadlight } from '@/composables/useHeadlight';
|
||||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||||
import { assetUrl } from '@/content/asset-url';
|
import { assetUrl } from '@/content/asset-url';
|
||||||
@@ -59,6 +62,7 @@ const sectionLabel = (id: SectionId): string => {
|
|||||||
|
|
||||||
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
||||||
const motion = useMotionPreferences();
|
const motion = useMotionPreferences();
|
||||||
|
useHeadlight(computed(() => motion.policy.value.allowSceneRequest));
|
||||||
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
||||||
const journeyBinding = useJourneySections(
|
const journeyBinding = useJourneySections(
|
||||||
canonicalSectionIds,
|
canonicalSectionIds,
|
||||||
@@ -211,6 +215,8 @@ onUnmounted(() => removePageListeners());
|
|||||||
:sample-frame="sampleSceneFrame"
|
:sample-frame="sampleSceneFrame"
|
||||||
@state-change="updateSceneState"
|
@state-change="updateSceneState"
|
||||||
/>
|
/>
|
||||||
|
<BootSequence :allow-motion="motion.policy.value.allowSceneRequest" />
|
||||||
|
<OperationsStrip />
|
||||||
<JourneyHud
|
<JourneyHud
|
||||||
:journey="journey"
|
:journey="journey"
|
||||||
:section-label="activeSectionLabel"
|
:section-label="activeSectionLabel"
|
||||||
|
|||||||
55
src/components/BootSequence.vue
Normal file
55
src/components/BootSequence.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ allowMotion: boolean }>();
|
||||||
|
|
||||||
|
const SEEN_KEY = 'portfolio.boot.seen';
|
||||||
|
const LINES = [
|
||||||
|
'night-shift v1.0 bucharest 03:00',
|
||||||
|
'ingress ........ tls ok',
|
||||||
|
'identity ....... access boundaries enforced',
|
||||||
|
'nextcloud ...... migrated, rollback ready',
|
||||||
|
'backups ........ verified',
|
||||||
|
'all systems quiet. starting route.',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const visible = shallowRef(false);
|
||||||
|
const shown = shallowRef(0);
|
||||||
|
const leaving = shallowRef(false);
|
||||||
|
const timers: number[] = [];
|
||||||
|
|
||||||
|
const finish = (): void => {
|
||||||
|
if (!visible.value || leaving.value) return;
|
||||||
|
leaving.value = true;
|
||||||
|
timers.push(window.setTimeout(() => { visible.value = false; }, 600));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (): void => finish();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let seen = false;
|
||||||
|
try { seen = window.sessionStorage.getItem(SEEN_KEY) === '1'; } catch { seen = false; }
|
||||||
|
if (!props.allowMotion || seen || navigator.webdriver) return;
|
||||||
|
try { window.sessionStorage.setItem(SEEN_KEY, '1'); } catch { /* optional */ }
|
||||||
|
visible.value = true;
|
||||||
|
LINES.forEach((_, index) => {
|
||||||
|
timers.push(window.setTimeout(() => { shown.value = index + 1; }, 260 + index * 280));
|
||||||
|
});
|
||||||
|
timers.push(window.setTimeout(finish, 260 + LINES.length * 280 + 500));
|
||||||
|
window.addEventListener('keydown', onKey, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
timers.forEach((id) => window.clearTimeout(id));
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="boot-sequence" :data-leaving="leaving || undefined" aria-hidden="true" @click="finish">
|
||||||
|
<ol class="boot-sequence__log">
|
||||||
|
<li v-for="(line, index) in LINES.slice(0, shown)" :key="index">{{ line }}</li>
|
||||||
|
</ol>
|
||||||
|
<span class="boot-sequence__skip">click or press any key to skip</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
33
src/components/OperationsStrip.vue
Normal file
33
src/components/OperationsStrip.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||||
|
import { bucharestClock, uptimeSince } from '@/lib/operations';
|
||||||
|
|
||||||
|
/** Start of the current hosting-admin role (content file: Trupa lui Răzuș, Aug 2025). */
|
||||||
|
const SHIFT_START = new Date('2025-08-01T00:00:00Z');
|
||||||
|
|
||||||
|
const clock = shallowRef<string | null>(null);
|
||||||
|
const shift = shallowRef<string | null>(null);
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
const tick = (): void => {
|
||||||
|
const now = new Date();
|
||||||
|
const { months, days } = uptimeSince(SHIFT_START, now);
|
||||||
|
clock.value = bucharestClock(now);
|
||||||
|
shift.value = `${months} mo ${days} d`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
tick();
|
||||||
|
timer = window.setInterval(tick, 30_000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="ops-strip" aria-hidden="true">
|
||||||
|
<span class="ops-strip__dot" />
|
||||||
|
<span>Bucharest <strong>{{ clock ?? '--:--' }}</strong></span>
|
||||||
|
<span class="ops-strip__sep">/</span>
|
||||||
|
<span>On shift <strong>{{ shift ?? '-- mo' }}</strong></span>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
50
src/composables/useHeadlight.ts
Normal file
50
src/composables/useHeadlight.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue';
|
||||||
|
|
||||||
|
const SURFACES = '.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer light for fine pointers only. Writes `--hx/--hy` (px) on the root for the
|
||||||
|
* headlight beam and `--mx/--my` (%) on the hovered surface for its spotlight.
|
||||||
|
*/
|
||||||
|
export function useHeadlight(enabled: Ref<boolean>): void {
|
||||||
|
let frame = 0;
|
||||||
|
let pending: PointerEvent | undefined;
|
||||||
|
let active = false;
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
frame = 0;
|
||||||
|
const event = pending;
|
||||||
|
pending = undefined;
|
||||||
|
if (event === undefined) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--hx', `${event.clientX}px`);
|
||||||
|
root.style.setProperty('--hy', `${event.clientY}px`);
|
||||||
|
const surface = (event.target as Element | null)?.closest<HTMLElement>(SURFACES);
|
||||||
|
if (surface !== null && surface !== undefined) {
|
||||||
|
const box = surface.getBoundingClientRect();
|
||||||
|
surface.style.setProperty('--mx', `${((event.clientX - box.left) / box.width) * 100}%`);
|
||||||
|
surface.style.setProperty('--my', `${((event.clientY - box.top) / box.height) * 100}%`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent): void => {
|
||||||
|
if (!enabled.value || event.pointerType === 'touch') return;
|
||||||
|
if (!active) {
|
||||||
|
active = true;
|
||||||
|
document.documentElement.dataset.headlight = 'on';
|
||||||
|
}
|
||||||
|
pending = event;
|
||||||
|
if (frame === 0) frame = requestAnimationFrame(flush);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!window.matchMedia('(pointer: fine)').matches) return;
|
||||||
|
window.addEventListener('pointermove', onMove, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
if (frame !== 0) cancelAnimationFrame(frame);
|
||||||
|
delete document.documentElement.dataset.headlight;
|
||||||
|
});
|
||||||
|
}
|
||||||
38
src/lib/operations.ts
Normal file
38
src/lib/operations.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface Uptime {
|
||||||
|
readonly months: number;
|
||||||
|
readonly days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole calendar months plus remaining days since `start`, clamped to zero. */
|
||||||
|
export function uptimeSince(start: Date, now: Date): Uptime {
|
||||||
|
if (now.getTime() <= start.getTime()) return { months: 0, days: 0 };
|
||||||
|
let months = (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + (now.getUTCMonth() - start.getUTCMonth());
|
||||||
|
let anchor = addMonths(start, months);
|
||||||
|
if (anchor.getTime() > now.getTime()) {
|
||||||
|
months -= 1;
|
||||||
|
anchor = addMonths(start, months);
|
||||||
|
}
|
||||||
|
const days = Math.floor((now.getTime() - anchor.getTime()) / 86_400_000);
|
||||||
|
return { months, days };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
return new Date(Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth() + months,
|
||||||
|
date.getUTCDate(),
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clockFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
timeZone: 'Europe/Bucharest',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function bucharestClock(now: Date): string {
|
||||||
|
return clockFormat.format(now);
|
||||||
|
}
|
||||||
185
src/styles/nightshift.css
Normal file
185
src/styles/nightshift.css
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/* Night Shift: cinematic layer on top of the night-drive world. Imported last. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ns-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='.55'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Film stock: grain + vignette above the world, below the UI. */
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-atmosphere);
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% 45%, transparent 55%, rgb(2 4 12 / 55%) 100%),
|
||||||
|
var(--ns-grain);
|
||||||
|
background-size: auto, 160px 160px;
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Operations strip */
|
||||||
|
.ops-strip {
|
||||||
|
position: fixed;
|
||||||
|
inset-block-end: 22px;
|
||||||
|
inset-inline: 0;
|
||||||
|
margin-inline: auto;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
z-index: var(--z-hud);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: 500 0.72rem/1 var(--mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ops-strip strong { color: var(--text); font-weight: 600; }
|
||||||
|
.ops-strip__sep { opacity: 0.4; }
|
||||||
|
.ops-strip__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6ee7a8;
|
||||||
|
box-shadow: 0 0 10px #6ee7a8;
|
||||||
|
}
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.ops-strip { display: inline-flex; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.ops-strip__dot { animation: ns-pulse 2.4s ease-in-out infinite; }
|
||||||
|
}
|
||||||
|
@keyframes ns-pulse { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* Boot sequence */
|
||||||
|
.boot-sequence {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: clamp(1.5rem, 8vw, 8rem);
|
||||||
|
background: #02040c;
|
||||||
|
color: var(--accent);
|
||||||
|
font: 500 clamp(0.8rem, 0.7rem + 0.5vw, 1.05rem)/1.8 var(--mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 600ms ease, filter 600ms ease;
|
||||||
|
}
|
||||||
|
.boot-sequence[data-leaving] { opacity: 0; filter: blur(6px); pointer-events: none; }
|
||||||
|
.boot-sequence__log { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.boot-sequence__log li::before { content: '> '; color: var(--world-magenta); }
|
||||||
|
.boot-sequence__log li:last-child::after {
|
||||||
|
content: '_';
|
||||||
|
margin-inline-start: 0.2em;
|
||||||
|
animation: ns-blink 700ms steps(1) infinite;
|
||||||
|
}
|
||||||
|
.boot-sequence__skip { color: var(--text-muted); font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
@keyframes ns-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* Headlight: soft beam that follows a fine pointer. */
|
||||||
|
html[data-headlight='on'] .reading-viewport::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-atmosphere);
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(420px circle at var(--hx, 50%) var(--hy, 50%), rgb(255 214 147 / 10%), transparent 70%);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spotlight borders on interactive surfaces. */
|
||||||
|
.project-case-study,
|
||||||
|
.project-card,
|
||||||
|
.contact-destination,
|
||||||
|
.infrastructure-flow-figure {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
html[data-headlight='on'] :is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(320px circle at var(--mx, 50%) var(--my, 50%), rgb(120 216 229 / 14%), transparent 65%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 240ms ease;
|
||||||
|
}
|
||||||
|
html[data-headlight='on'] :is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure):hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter numerals: outlined, drifting section numbers. */
|
||||||
|
.reading-stage { counter-reset: chapter; }
|
||||||
|
.portfolio-section > h2,
|
||||||
|
.project-stage__lead > h2 { position: relative; }
|
||||||
|
.portfolio-section { counter-increment: chapter; }
|
||||||
|
.portfolio-section > h2::before,
|
||||||
|
.project-stage__lead > h2::before {
|
||||||
|
content: '0' counter(chapter);
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 100%;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
margin-block-end: 0.15em;
|
||||||
|
font: 700 var(--step-4)/0.85 var(--mono);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-stroke: 1px rgb(120 216 229 / 38%);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.portfolio-section > h2::before,
|
||||||
|
.project-stage__lead > h2::before { font-size: var(--step-3); }
|
||||||
|
}
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.portfolio-section > h2::before,
|
||||||
|
.project-stage__lead > h2::before { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kinetic type + scroll-linked reveals (progressive enhancement). */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.hero-name__text {
|
||||||
|
font-variation-settings: 'wght' 800;
|
||||||
|
transition: font-variation-settings 400ms ease, letter-spacing 400ms ease;
|
||||||
|
}
|
||||||
|
.hero-name:hover .hero-name__text {
|
||||||
|
font-variation-settings: 'wght' 520;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
.portfolio-section > h2::before,
|
||||||
|
.project-stage__lead > h2::before {
|
||||||
|
animation: ns-drift linear both;
|
||||||
|
animation-timeline: view();
|
||||||
|
animation-range: entry 0% cover 60%;
|
||||||
|
}
|
||||||
|
.project-card,
|
||||||
|
.project-case-study {
|
||||||
|
animation: ns-rise linear both;
|
||||||
|
animation-timeline: view();
|
||||||
|
animation-range: entry 0% entry 60%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ns-drift {
|
||||||
|
from { transform: translateX(-12%); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes ns-rise {
|
||||||
|
from { transform: translateY(28px); opacity: 0.2; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body::after, .ops-strip, .boot-sequence { display: none !important; }
|
||||||
|
.portfolio-section > h2::before { display: none; }
|
||||||
|
}
|
||||||
21
tests/unit/operations.test.ts
Normal file
21
tests/unit/operations.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { bucharestClock, uptimeSince } from '@/lib/operations';
|
||||||
|
|
||||||
|
describe('uptimeSince', () => {
|
||||||
|
const start = new Date('2025-08-01T00:00:00Z');
|
||||||
|
it('is zero at the start', () => {
|
||||||
|
expect(uptimeSince(start, start)).toEqual({ months: 0, days: 0 });
|
||||||
|
});
|
||||||
|
it('counts whole months and remaining days', () => {
|
||||||
|
expect(uptimeSince(start, new Date('2026-10-10T00:00:00Z'))).toEqual({ months: 14, days: 9 });
|
||||||
|
});
|
||||||
|
it('clamps to zero before the start', () => {
|
||||||
|
expect(uptimeSince(start, new Date('2025-01-01T00:00:00Z'))).toEqual({ months: 0, days: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bucharestClock', () => {
|
||||||
|
it('formats HH:MM in Europe/Bucharest', () => {
|
||||||
|
expect(bucharestClock(new Date('2026-01-15T10:05:00Z'))).toBe('12:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user