This commit is contained in:
Rene Kievits
2025-12-18 01:30:52 +01:00
commit 6438660b03
78 changed files with 14230 additions and 0 deletions
@@ -0,0 +1,38 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
div
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
v-progress-circular(
:model-value="accuracyPercent"
color="#00cec9"
size="100"
width="10"
bg-color="grey-darken-3"
)
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
accuracy: {
type: Object,
default: () => ({
correct: 0,
total: 0
})
}
});
const accuracyPercent = computed(() => {
if (!props.accuracy || !props.accuracy.total) return 100;
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,73 @@
<template lang="pug">
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
.d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
| {{ $t('stats.srsDistribution') }}
v-chip.font-weight-bold(
size="x-small"
color="white"
variant="tonal"
) {{ totalItems }}
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
v-for="lvl in 10"
:key="lvl"
)
.text-caption.font-weight-bold.mb-2.transition-text(
:class="getCount(lvl) > 0 ? 'text-white' : 'text-grey-darken-3'"
) {{ getCount(lvl) }}
.srs-track
.srs-fill(:style="{\
height: getBarHeight(getCount(lvl)) + '%',\
background: getSRSColor(lvl),\
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
}")
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
style="font-size: 10px !important;"
) {{ toRoman(lvl) }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const getCount = (lvl) => props.distribution?.[lvl] || 0;
const getBarHeight = (count) => {
const max = Math.max(...Object.values(props.distribution || {}), 1);
if (count > 0 && (count / max) * 100 < 4) return 4;
return (count / max) * 100;
};
const getSRSColor = (lvl) => {
const colors = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
10: '#ffd700'
};
return colors[lvl] || '#444';
};
const toRoman = (num) => {
const lookup = {
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
};
return lookup[num] || num;
};
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,41 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
v-icon(color="#ffeaa7" start) mdi-clock-outline
| {{ $t('stats.next24') }}
.forecast-list.flex-grow-1(v-if="hasUpcoming")
div(v-for="(count, hour) in forecast" :key="hour")
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
span.text-body-2.text-grey-lighten-1
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
v-chip.font-weight-bold(
size="small"
color="#2f3542"
style="color: #00cec9 !important;"
) {{ count }}
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
| {{ $t('stats.noIncoming') }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
forecast: {
type: Object,
default: () => ({})
}
});
const hasUpcoming = computed(() => {
return props.forecast && props.forecast.some(c => c > 0);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
.border-b-subtle {
border-bottom: 1px solid rgb(255 255 255 / 5%);
}
</style>
@@ -0,0 +1,32 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-center.mb-3
.d-flex.align-center
v-icon(color="#ff7675" start size="small") mdi-ghost
div
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
v-chip.font-weight-bold.text-black.w-100.justify-center(
size="x-small"
color="red-accent-2"
variant="flat"
) {{ ghost.accuracy }}%
.text-center.py-2.text-caption.text-grey(v-else)
| {{ $t('stats.noGhosts') }}
</template>
<script setup>
const props = defineProps({
ghosts: {
type: Array,
default: () => []
}
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,45 @@
<template lang="pug">
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
.d-flex.justify-space-between.align-center.mb-2
.d-flex.align-center
v-icon(color="secondary" start size="small") mdi-trophy-outline
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
v-progress-linear(
:model-value="masteryPercent"
color="primary"
height="8"
rounded
bg-color="grey-darken-3"
striped
)
.text-caption.text-medium-emphasis.mt-2.text-right
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const masteredCount = computed(() => {
const dist = props.distribution || {};
return (dist[6] || 0);
});
const masteryPercent = computed(() => {
if (totalItems.value === 0) return 0;
return Math.round((masteredCount.value / totalItems.value) * 100);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,89 @@
<template lang="pug">
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="secondary" start) mdi-calendar-check
| {{ $t('stats.consistency') }}
.legend-container
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
.legend-box.level-0
.legend-box.level-1
.legend-box.level-2
.legend-box.level-3
.legend-box.level-4
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
.heatmap-container
.heatmap-year
.heatmap-week(v-for="(week, wIdx) in weeks" :key="wIdx")
.heatmap-day-wrapper(v-for="(day, dIdx) in week" :key="dIdx")
v-tooltip(location="top" open-delay="100")
template(v-slot:activator="{ props }")
.heatmap-cell(
v-bind="props"
:class="[\
isToday(day.date) ? 'today-cell' : '',\
getHeatmapClass(day.count)\
]"
)
.text-center
.font-weight-bold {{ formatDate(day.date) }}
div {{ $t('stats.reviewsCount', { count: day.count }) }}
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
const props = defineProps({
heatmapData: {
type: Object,
default: () => ({})
}
});
const weeks = computed(() => {
const data = props.heatmapData || {};
const w = [];
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - (52 * 7));
const dayOfWeek = startDate.getDay();
startDate.setDate(startDate.getDate() - dayOfWeek);
let currentWeek = [];
for (let i = 0; i < 371; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
currentWeek.push({
date: dateStr,
count: data[dateStr] || 0
});
if (currentWeek.length === 7) {
w.push(currentWeek);
currentWeek = [];
}
}
return w;
});
const getHeatmapClass = (count) => {
if (count === 0) return 'level-0';
if (count <= 5) return 'level-1';
if (count <= 10) return 'level-2';
if (count <= 20) return 'level-3';
return 'level-4';
};
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
const isToday = (dateStr) => dateStr === new Date().toISOString().split('T')[0];
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,59 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-start.mb-2
div
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
.d-flex.align-center
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
.text-h6.text-grey {{ $t('stats.days') }}
.text-center
v-tooltip(
location="start"
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
)
template(v-slot:activator="{ props }")
v-avatar(
v-bind="props"
size="48"
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
style="border: 1px solid;"
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
)
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
.d-flex.justify-space-between.align-center.mt-2.px-1
.d-flex.flex-column.align-center(
v-for="(day, idx) in (streak?.history || [])"
:key="idx"
)
.streak-dot.mb-1(:class="{ 'active': day.active }")
v-icon(v-if="day.active" size="12" color="black") mdi-check
.text-grey.text-uppercase(style="font-size: 10px;")
| {{ getDayLabel(day.date) }}
</template>
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
streak: {
type: Object,
required: true,
default: () => ({
current: 0,
history: [],
shield: { ready: false, cooldown: 0 }
})
}
});
const { locale } = useI18n();
const getDayLabel = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
};
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
@@ -0,0 +1,76 @@
<template lang="pug">
.grid-area-full.text-center.mb-4
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
.d-flex.justify-center.align-center.flex-column
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
@click="$emit('start', 'shuffled')"
height="80"
width="280"
rounded="xl"
color="#00cec9"
:disabled="queueLength === 0"
)
v-icon(size="32" start) mdi-brush
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
v-chip.ml-3.font-weight-bold(
v-if="queueLength > 0"
color="#1e1e24"
variant="flat"
size="default"
style="color: white !important;"
) {{ queueLength }}
v-fade-transition
v-btn.mt-4.text-caption.font-weight-bold(
v-if="hasLowerLevels"
@click="$emit('start', 'priority')"
variant="plain"
color="grey-lighten-1"
prepend-icon="mdi-sort-ascending"
:ripple="false"
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
.text-caption.text-grey.mt-2(v-if="queueLength === 0")
| {{ $t('hero.nextIn') }} {{ nextReviewTime }}
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
queueLength: {
type: Number,
required: true
},
hasLowerLevels: {
type: Boolean,
default: false
},
lowerLevelCount: {
type: Number,
default: 0
},
forecast: {
type: Object,
default: () => ({})
}
});
defineEmits(['start']);
const { t } = useI18n();
const nextReviewTime = computed(() => {
if (!props.forecast) return "a while";
const idx = props.forecast.findIndex(c => c > 0);
if (idx === -1) return "a while";
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
});
</script>
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>