sunrise-sunset: add score history
This commit is contained in:
parent
ffac69e6ce
commit
801bb6586a
4 changed files with 307 additions and 91 deletions
|
|
@ -142,8 +142,8 @@
|
|||
doesUserHaveGuessingHistory={$guessingHistory.mostRecentGuessDate !==
|
||||
undefined}
|
||||
{currentStreakLength}
|
||||
correctGuessCount={$guessingHistory.correctDays.length}
|
||||
incorrectGuessCount={$guessingHistory.incorrectDays.length}
|
||||
correctGuessDays={$guessingHistory.correctDays}
|
||||
incorrectGuessDays={$guessingHistory.incorrectDays}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { SunriseSunsetStreakCalculator } from "./SunriseSunsetStreakCalculator.js";
|
||||
import { browser } from "$app/environment";
|
||||
export let doesUserHaveGuessingHistory: boolean;
|
||||
export let correctGuessCount: number;
|
||||
export let incorrectGuessCount: number;
|
||||
export let correctGuessDays: string[];
|
||||
export let incorrectGuessDays: string[];
|
||||
export let currentStreakLength: number;
|
||||
|
||||
$: totalGuessCount = correctGuessCount + incorrectGuessCount;
|
||||
const todayAsString = formatDate(new Date(), "yyyy-MM-dd");
|
||||
$: totalGuessCount = incorrectGuessDays.length + correctGuessDays.length;
|
||||
const calculator = new SunriseSunsetStreakCalculator(todayAsString);
|
||||
let hasTextBeenCopied = false;
|
||||
|
||||
$: historyStatement = calculator.getShareableStatement(
|
||||
correctGuessDays,
|
||||
incorrectGuessDays,
|
||||
new Date()
|
||||
);
|
||||
|
||||
function copyHistory() {
|
||||
if (browser) {
|
||||
navigator.clipboard.writeText(historyStatement);
|
||||
hasTextBeenCopied = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="score">
|
||||
|
|
@ -12,17 +31,13 @@
|
|||
<h2 class="score__title">Your Score Card</h2>
|
||||
{#if doesUserHaveGuessingHistory}
|
||||
<p class="score__text">
|
||||
You've made {totalGuessCount}
|
||||
{totalGuessCount === 1 ? "guess" : "guesses"} so far.
|
||||
</p>
|
||||
<p class="score__text">
|
||||
Your current streak is {currentStreakLength}
|
||||
{currentStreakLength === 1 ? "day" : "days"}.
|
||||
</p>
|
||||
<p class="score__text">
|
||||
You've guessed correctly {Number(correctGuessCount / totalGuessCount) *
|
||||
100}% of the time.
|
||||
{historyStatement}
|
||||
</p>
|
||||
<button on:click={() => copyHistory()}> Copy to Clipboard </button>
|
||||
|
||||
{#if hasTextBeenCopied}
|
||||
<p>Copied!</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="score__text">You've not guessed yet.</p>
|
||||
{/if}
|
||||
|
|
@ -54,4 +69,9 @@
|
|||
.score__title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.score__text {
|
||||
white-space: pre-line;
|
||||
line-height: 135%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { SunriseSunsetStreakCalculator } from './SunriseSunsetStreakCalculator.js';
|
||||
|
||||
describe('SunriseSunsetStreakCalculator', () => {
|
||||
describe(`Streak Length`, () => {
|
||||
const anyDay = '2023-01-29';
|
||||
it(`should recognise an empty streak`, () => {
|
||||
// GIVEN
|
||||
|
|
@ -74,3 +75,76 @@ describe('SunriseSunsetStreakCalculator', () => {
|
|||
expect(currentStreakLength).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`Streak Visualisation`, () => {
|
||||
let calculator: SunriseSunsetStreakCalculator;
|
||||
|
||||
beforeAll(() => {
|
||||
calculator = new SunriseSunsetStreakCalculator('2023-01-29');
|
||||
});
|
||||
|
||||
it(`Sound visualise an empty streak`, () => {
|
||||
// GIVEN
|
||||
const correctDays = [];
|
||||
const incorrectDays = [];
|
||||
|
||||
// WHEN
|
||||
const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrectDays);
|
||||
|
||||
// THEN
|
||||
expect(emojiVisualisation).toBe('');
|
||||
});
|
||||
|
||||
it(`should visualise a simple correct-incorrect two day streak`, () => {
|
||||
// GIVEN
|
||||
const correctDays = ['2023-01-20'];
|
||||
const incorrectDays = ['2023-01-19'];
|
||||
|
||||
// WHEN
|
||||
const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrectDays);
|
||||
|
||||
// THEN
|
||||
expect(emojiVisualisation).toBe('🌞🌚');
|
||||
});
|
||||
|
||||
it(`should handle a missing day`, () => {
|
||||
// GIVEN
|
||||
const correctDays = ['2023-01-20', '2023-01-21'];
|
||||
const incorrect = ['2023-01-18', '2023-01-22'];
|
||||
|
||||
// WHEN
|
||||
const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrect);
|
||||
|
||||
// THEN
|
||||
expect(emojiVisualisation).toBe('🌚🌞🌞🥷🌚');
|
||||
});
|
||||
|
||||
it(`should get a fully shareable streak when all things are empty`, () => {
|
||||
// GIVEN
|
||||
const correctDays = [];
|
||||
const incorrectDays = [];
|
||||
const today = new Date('2023-01-20T21:52Z');
|
||||
|
||||
// WHEN
|
||||
const shareableStatement = calculator.getShareableStatement(correctDays, incorrectDays, today);
|
||||
|
||||
// THEN
|
||||
const expected = `Sunrise, Sunset?\n2023-01-20\n\nCurrent Streak: 0\nLongest Streak: 0`;
|
||||
expect(shareableStatement).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it(`should get a fully shareable streak`, () => {
|
||||
// GIVEN
|
||||
const correctDays = ['2023-01-10', '2023-01-11', '2023-01-15', '2023-01-16'];
|
||||
const incorrectDays = ['2023-01-12', '2023-01-13'];
|
||||
const today = new Date('2023-01-20T21:52Z');
|
||||
|
||||
// WHEN
|
||||
const shareableStatement = calculator.getShareableStatement(correctDays, incorrectDays, today);
|
||||
|
||||
// THEN
|
||||
const expected = `Sunrise, Sunset?\n2023-01-20\n🌞🌞🥷🌚🌚🌞🌞\nCurrent Streak: 2\nLongest Streak: 2`;
|
||||
expect(shareableStatement).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,90 @@
|
|||
import { parse, isAfter, differenceInCalendarDays } from 'date-fns';
|
||||
import { parse, format as formatDate, isAfter, isBefore, isSameDay, addDays, differenceInCalendarDays } from 'date-fns';
|
||||
|
||||
export class SunriseSunsetStreakCalculator {
|
||||
private readonly todayDate: Date;
|
||||
constructor(private readonly today: string) {
|
||||
this.todayDate = parse(today, 'yyyy-MM-dd', new Date());
|
||||
enum GuessType {
|
||||
correct = 'correct',
|
||||
incorrect = 'incorrect',
|
||||
missing = 'missing',
|
||||
}
|
||||
|
||||
getStreakLength(correctDays: string[]): number {
|
||||
if (correctDays.length === 0) {
|
||||
console.log(`No correct days, returning 0.`);
|
||||
return 0;
|
||||
} else if (!correctDays.some((day) => day === this.today)) {
|
||||
console.log(`Today is not in the list of correct days`);
|
||||
return 0;
|
||||
class SunriseSunsetDayGuess {
|
||||
readonly day: Date;
|
||||
readonly emoji: string;
|
||||
|
||||
private static getEmojiForGuessType(guessType: GuessType): string {
|
||||
switch (guessType) {
|
||||
case GuessType.correct:
|
||||
return '🌞';
|
||||
case GuessType.incorrect:
|
||||
return '🌚';
|
||||
case GuessType.missing:
|
||||
return '🥷';
|
||||
}
|
||||
}
|
||||
|
||||
const daysAsDates = correctDays.map((day) => parse(day, 'yyyy-MM-dd', new Date()));
|
||||
const sortedDays = daysAsDates.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
const sortedDaysWithoutToday = correctDays
|
||||
.filter((day) => day !== this.today)
|
||||
.map((day) => parse(day, 'yyyy-MM-dd', new Date()))
|
||||
.sort((a, b) => b.getTime() - a.getTime());
|
||||
const daysBetweenTodayAndMostRecentDay = differenceInCalendarDays(this.todayDate, sortedDaysWithoutToday[0]);
|
||||
|
||||
if (daysBetweenTodayAndMostRecentDay > 1) {
|
||||
console.log(`Today is more than one day after the most recent correct day`);
|
||||
return 1;
|
||||
constructor(dayString: string, private readonly guessType: GuessType) {
|
||||
this.day = parse(dayString, 'yyyy-MM-dd', new Date());
|
||||
this.emoji = SunriseSunsetDayGuess.getEmojiForGuessType(guessType);
|
||||
}
|
||||
}
|
||||
|
||||
class SunriseSunsetDayGuessSet {
|
||||
private readonly sortedGuesses: SunriseSunsetDayGuess[];
|
||||
private readonly sortedGuessesWithMissingDays: SunriseSunsetDayGuess[];
|
||||
|
||||
constructor(guesses: SunriseSunsetDayGuess[]) {
|
||||
this.sortedGuesses = guesses.sort((a, b) => b.day.getTime() - a.day.getTime());
|
||||
const missingGuesses = this.getGuessesForMissingDays();
|
||||
this.sortedGuessesWithMissingDays = SunriseSunsetDayGuessSet.sortGuesses([...guesses, ...missingGuesses]);
|
||||
}
|
||||
|
||||
private static sortGuesses(guesses: SunriseSunsetDayGuess[]): SunriseSunsetDayGuess[] {
|
||||
return guesses.sort((a, b) => b.day.getTime() - a.day.getTime());
|
||||
}
|
||||
|
||||
private isGuessPresentForDay(date: Date): boolean {
|
||||
return this.sortedGuesses.some((guess) => isSameDay(guess.day, date));
|
||||
}
|
||||
|
||||
private getGuessesForMissingDays(): SunriseSunsetDayGuess[] {
|
||||
const earliestDate: Date | undefined = this.sortedGuesses[this.sortedGuesses.length - 1]?.day;
|
||||
|
||||
if (!earliestDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const latestDate = this.sortedGuesses[0].day;
|
||||
let guessesForMissingDays: SunriseSunsetDayGuess[] = [];
|
||||
|
||||
let currentDay = earliestDate;
|
||||
|
||||
while (isBefore(currentDay, latestDate)) {
|
||||
const isPresent = this.isGuessPresentForDay(currentDay);
|
||||
|
||||
if (!isPresent) {
|
||||
guessesForMissingDays = [
|
||||
...guessesForMissingDays,
|
||||
new SunriseSunsetDayGuess(formatDate(currentDay, 'yyyy-MM-dd'), GuessType.missing),
|
||||
];
|
||||
}
|
||||
currentDay = addDays(currentDay, 1);
|
||||
}
|
||||
|
||||
return guessesForMissingDays;
|
||||
}
|
||||
|
||||
getLastDays(maxDays = 10): SunriseSunsetDayGuess[] {
|
||||
return this.sortedGuessesWithMissingDays.slice(0, maxDays);
|
||||
}
|
||||
}
|
||||
|
||||
class SunriseSunsetStreak {
|
||||
private readonly longestStreak: number;
|
||||
private readonly allStreaks: number[];
|
||||
readonly mostRecentStreak: number;
|
||||
|
||||
constructor(correctDays: Date[]) {
|
||||
// A (reverse-order) list of streaks in the lis tof correct days.
|
||||
const sortedDays = correctDays.sort((a, b) => b.getTime() - a.getTime());
|
||||
const allStreaks: number[] = sortedDays.reduce((streaks: number[], day, index, days) => {
|
||||
const currentStreakLength = streaks[0] ?? 1;
|
||||
const daysBetween = differenceInCalendarDays(day, days[index + 1]);
|
||||
|
|
@ -46,6 +101,73 @@ export class SunriseSunsetStreakCalculator {
|
|||
}, []);
|
||||
|
||||
// The streaks are in reverse order, so the most recent streak is the last one.
|
||||
return allStreaks[allStreaks.length - 1];
|
||||
this.mostRecentStreak = allStreaks[allStreaks.length - 1] ?? 0;
|
||||
this.longestStreak = Math.max(...allStreaks);
|
||||
this.allStreaks = allStreaks;
|
||||
}
|
||||
}
|
||||
|
||||
export class SunriseSunsetStreakCalculator {
|
||||
private readonly todayDate: Date;
|
||||
constructor(private readonly today: string) {
|
||||
this.todayDate = parse(today, 'yyyy-MM-dd', new Date());
|
||||
}
|
||||
|
||||
private daysAsDates(dayStrings: string[]): Date[] {
|
||||
return dayStrings.map((day) => parse(day, 'yyyy-MM-dd', new Date()));
|
||||
}
|
||||
|
||||
getEmojiForHistory(correctDays: string[], incorrectDays: string[], length = 7): string {
|
||||
const correctGuesses = correctDays.map((day) => new SunriseSunsetDayGuess(day, GuessType.correct));
|
||||
const incorrectGuesses = incorrectDays.map((day) => new SunriseSunsetDayGuess(day, GuessType.incorrect));
|
||||
const allGuesses = new SunriseSunsetDayGuessSet([...correctGuesses, ...incorrectGuesses]);
|
||||
|
||||
const streak: string = allGuesses.getLastDays(length).reduce((streak, guess) => {
|
||||
return streak + guess.emoji;
|
||||
}, '');
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
getShareableStatement(correctDays: string[], incorrectDays: string[], today: Date, joiningString = '\n'): string {
|
||||
const emoji = this.getEmojiForHistory(correctDays, incorrectDays);
|
||||
const todayFormatted = formatDate(today, 'yyyy-MM-dd');
|
||||
const streak = new SunriseSunsetStreak(this.daysAsDates(correctDays));
|
||||
|
||||
return [
|
||||
`Sunrise, Sunset?`,
|
||||
todayFormatted,
|
||||
emoji,
|
||||
`Current Streak: ${streak.mostRecentStreak}`,
|
||||
`Longest Streak: ${streak.mostRecentStreak}`,
|
||||
].join(joiningString);
|
||||
}
|
||||
|
||||
getStreakLength(correctDays: string[]): number {
|
||||
if (correctDays.length === 0) {
|
||||
console.log(`No correct days, returning 0.`);
|
||||
return 0;
|
||||
} else if (!correctDays.some((day) => day === this.today)) {
|
||||
console.log(`Today is not in the list of correct days`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const daysAsDates = this.daysAsDates(correctDays);
|
||||
|
||||
const sortedDaysWithoutToday = correctDays
|
||||
.filter((day) => day !== this.today)
|
||||
.map((day) => parse(day, 'yyyy-MM-dd', new Date()))
|
||||
.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
const daysBetweenTodayAndMostRecentDay = differenceInCalendarDays(this.todayDate, sortedDaysWithoutToday[0]);
|
||||
|
||||
if (daysBetweenTodayAndMostRecentDay > 1) {
|
||||
console.log(`Today is more than one day after the most recent correct day`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const streaks = new SunriseSunsetStreak(daysAsDates);
|
||||
|
||||
return streaks.mostRecentStreak;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue