mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-10-02 03:46:45 -05:00
Merge pull request #132 from misenhower/codex/splatfest-history-dates
Clarify dates and countdowns across schedules, gear, and social images
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
"in": "in {time}",
|
||||
"remaining": "{time} übrig",
|
||||
"until": "Bis {time}",
|
||||
"left": "{time} verbleibend",
|
||||
"left": "Noch {time}",
|
||||
"d": "{n}Tg | {n}Tg",
|
||||
"h": "{n}Std | {n}Std",
|
||||
"m": "{n}Min | {n}Min",
|
||||
|
||||
@@ -50,6 +50,7 @@ export const regionalLocales = {
|
||||
export const defaultLocale = locales.find(l => l.code === 'en-US');
|
||||
|
||||
const datetimeFormats = {
|
||||
weekday: { weekday: 'short' },
|
||||
dateTimeShort: { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' },
|
||||
dateTimeShortWeekday: { month: 'numeric', weekday: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' },
|
||||
time: { hour: 'numeric', minute: '2-digit' },
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTimeStore } from '@/stores/time';
|
||||
|
||||
export function formatHistoricalDateRange(startTime, endTime, locale) {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return formatter.formatRange(new Date(startTime), new Date(endTime));
|
||||
}
|
||||
|
||||
export function getDurationParts(value) {
|
||||
let negative = (value < 0) ? '-' : '';
|
||||
value = Math.abs(value);
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-zinc-300">
|
||||
<template v-if="startsAfterToday">
|
||||
{{ $d(props.schedule.startTime, 'weekday') }}
|
||||
</template>
|
||||
{{ $d(props.schedule.startTime, 'time') }}
|
||||
–
|
||||
{{ $d(props.schedule.endTime, 'time') }}
|
||||
@@ -46,6 +49,9 @@
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-zinc-300 text-shadow">
|
||||
<template v-if="startsAfterToday">
|
||||
{{ $d(props.schedule.startTime, 'weekday') }}
|
||||
</template>
|
||||
{{ $d(props.schedule.startTime, 'time') }}
|
||||
–
|
||||
{{ $d(props.schedule.endTime, 'time') }}
|
||||
@@ -84,6 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import StageImage from './StageImage.vue';
|
||||
import RuleIcon from './RuleIcon.vue';
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
@@ -96,4 +103,11 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const time = useTimeStore();
|
||||
|
||||
const startsAfterToday = computed(() => {
|
||||
const tomorrow = new Date(time.now);
|
||||
tomorrow.setHours(24, 0, 0, 0);
|
||||
|
||||
return props.schedule && Date.parse(props.schedule.startTime) >= tomorrow.getTime();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
|
||||
<div class="font-splatoon2 text-splatoon-yellow text-center text-sm lg:text-base text-shadow mx-2 ss:hidden">
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-6 w-4/5 mx-auto" />
|
||||
<template v-else-if="historyMode">
|
||||
{{ formatHistoricalDateRange(festival.startTime, festival.endTime, $i18n.locale) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $d(festival.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
@@ -60,6 +63,7 @@ import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import ProductContainer from './ProductContainer.vue';
|
||||
import SquidTape from './SquidTape.vue';
|
||||
import { formatHistoricalDateRange } from '@/common/time';
|
||||
import { STATUS_PAST, STATUS_ACTIVE, STATUS_UPCOMING } from '@/stores/splatfests';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -65,12 +65,10 @@
|
||||
|
||||
<!-- Time left -->
|
||||
<div class="absolute top-1 left-6">
|
||||
<div class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
<SkeletonBlock v-if="loading" class="h-4 w-16" />
|
||||
<template v-else>
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</template>
|
||||
<div v-if="loading" class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
<SkeletonBlock class="h-4 w-16" />
|
||||
</div>
|
||||
<GearExpiry v-else :end-time="props.gear.saleEndTime" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,7 +76,7 @@
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
import GearExpiry from './GearExpiry.vue';
|
||||
|
||||
const props = defineProps({
|
||||
gear: Object,
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex">
|
||||
<div class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</div>
|
||||
<GearExpiry :end-time="props.gear.saleEndTime" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -104,7 +102,7 @@
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import SquidTape from '@/components/SquidTape.vue';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
import GearExpiry from './GearExpiry.vue';
|
||||
import { getGesotownGearUrl } from '@/common/links';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
48
src/components/gear/GearExpiry.test.mjs
Normal file
48
src/components/gear/GearExpiry.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSSRApp } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import messages from '@/assets/i18n/en-US.json';
|
||||
import { useTimeStore } from '@/stores/time.mjs';
|
||||
import GearExpiry from './GearExpiry.vue';
|
||||
|
||||
describe('gear expiry labels', () => {
|
||||
it.each([
|
||||
[1, '1 second left'],
|
||||
[45, '45 seconds left'],
|
||||
[59, '59 seconds left'],
|
||||
[60, '1 minute left'],
|
||||
[3600, '1 hour left'],
|
||||
])('shows the appropriate label with %i seconds left', async (seconds, label) => {
|
||||
const endTime = '2026-09-28T00:00:00Z';
|
||||
const app = createSSRApp(GearExpiry, { endTime });
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
useTimeStore(pinia).setNow(Date.parse(endTime) - seconds * 1000);
|
||||
|
||||
app.use(createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: { 'en-US': messages },
|
||||
datetimeFormats: {
|
||||
'en-US': {
|
||||
dateTimeShortWeekday: {
|
||||
weekday: 'short',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const html = await renderToString(app);
|
||||
|
||||
expect(html).toContain(label);
|
||||
expect(html).toContain('title="Until Mon, 9/28, 12:00 AM"');
|
||||
});
|
||||
});
|
||||
34
src/components/gear/GearExpiry.vue
Normal file
34
src/components/gear/GearExpiry.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<span :title="deadline" class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
<template v-if="remainingSeconds < 60">
|
||||
{{ $t('time.left', { time: $t('time.seconds', { n: remainingSeconds }, remainingSeconds) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(endTime) }) }}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
import { useTimeStore } from '@/stores/time.mjs';
|
||||
|
||||
const props = defineProps({
|
||||
endTime: { type: String, required: true },
|
||||
});
|
||||
|
||||
const time = useTimeStore();
|
||||
const { t, d } = useI18n();
|
||||
|
||||
const deadline = computed(() => t('time.until', {
|
||||
time: d(props.endTime, 'dateTimeShortWeekday'),
|
||||
}));
|
||||
|
||||
const remainingSeconds = computed(() => {
|
||||
const remaining = Date.parse(props.endTime) - time.now;
|
||||
|
||||
return Math.max(0, Math.ceil(remaining / 1000));
|
||||
});
|
||||
</script>
|
||||
@@ -6,41 +6,41 @@
|
||||
|
||||
<div class="h-12 m-4 bg-black/50 backdrop-blur-xs rounded-full absolute bottom-0 inset-x-0">
|
||||
<div class="flex justify-between h-full font-splatoon2 text-sm text-zinc-300">
|
||||
<div class="flex justify-start items-center space-x-6 ml-4">
|
||||
<div class="flex justify-start items-center space-x-4 ml-4 shrink-0">
|
||||
<div>
|
||||
<img src="@/assets/img/favicon.svg" class="h-16 -my-8" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-8">
|
||||
<div class="text-3xl text-zinc-50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-2xl text-zinc-50">
|
||||
{{ props.header }}
|
||||
</div>
|
||||
<div>
|
||||
<img src="@/assets/img/bluesky-white.svg" width="15" height="15" class="inline" />
|
||||
@splatoon3.ink
|
||||
</div>
|
||||
<!-- <div>
|
||||
<img src="@/assets/img/fediverse-white.svg" width="14" height="14" class="inline m-[3px]" />
|
||||
@splatoon3ink@splatoon3.ink
|
||||
</div> -->
|
||||
<!-- <div>
|
||||
splatoon3.ink
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex justify-end items-center mr-6">
|
||||
{{ formatDateTime(time.now) }}
|
||||
</div> -->
|
||||
<div class="flex items-center gap-5 mx-5 whitespace-nowrap">
|
||||
<div class="text-zinc-100">
|
||||
{{ timestamps[0] }}
|
||||
</div>
|
||||
<div class="grid grid-flow-col grid-rows-2 gap-x-4 text-xs leading-5 text-right">
|
||||
<div v-for="timestamp in timestamps.slice(1)" :key="timestamp">
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 right-4 z-50">
|
||||
<div class="fixed bottom-20 right-4 z-50">
|
||||
<TimeOffsetSelector v-if="isDev" class="mb-4" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onUnmounted, watch, watchEffect } from 'vue';
|
||||
import { computed, nextTick, onUnmounted, watch, watchEffect } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { markScreenshotReady, screenshotReadyAttribute } from '@/common/screenshot.mjs';
|
||||
import { useDataStore } from '@/stores/data';
|
||||
@@ -80,18 +80,31 @@ onUnmounted(() => {
|
||||
watchEffect(() => {
|
||||
if (route.query.time) {
|
||||
time.stopUpdatingNow();
|
||||
time.setNow(route.query.time);
|
||||
time.setNow(Number(route.query.time));
|
||||
}
|
||||
});
|
||||
|
||||
// function formatDateTime(date) {
|
||||
// date = new Date(date);
|
||||
const timestampFormats = [
|
||||
{ locale: 'en-GB', timeZone: 'UTC', hour12: false, month: 'long' },
|
||||
{ locale: 'en-US', timeZone: 'America/Los_Angeles', hour12: true },
|
||||
{ locale: 'en-US', timeZone: 'America/New_York', hour12: true },
|
||||
{ locale: 'en-GB', timeZone: 'Europe/London', hour12: false },
|
||||
{ locale: 'ja-JP', timeZone: 'Asia/Tokyo', hour12: false },
|
||||
].map(({ locale, ...options }) => new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
...options,
|
||||
}));
|
||||
|
||||
// return date.toLocaleString(undefined, {
|
||||
// dateStyle: 'long',
|
||||
// timeStyle: 'short',
|
||||
// })
|
||||
// }
|
||||
const timestamps = computed(() => {
|
||||
const date = new Date(time.now);
|
||||
|
||||
return timestampFormats.map(format => format.format(date).replace(/\s+at\s+/u, ' '));
|
||||
});
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user