From 801bb6586aec4a534f3a79ae63536bff70d245ee Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 31 Jan 2023 22:12:06 +0000 Subject: [PATCH] sunrise-sunset: add score history --- src/routes/sunrise-sunset/+page.svelte | 4 +- .../sunrise-sunset/ScoreCardSection.svelte | 46 +++-- .../SunriseSunsetStreakCalculator.test.ts | 180 ++++++++++++------ .../SunriseSunsetStreakCalculator.ts | 168 +++++++++++++--- 4 files changed, 307 insertions(+), 91 deletions(-) diff --git a/src/routes/sunrise-sunset/+page.svelte b/src/routes/sunrise-sunset/+page.svelte index b8202c2..217f9cb 100644 --- a/src/routes/sunrise-sunset/+page.svelte +++ b/src/routes/sunrise-sunset/+page.svelte @@ -142,8 +142,8 @@ doesUserHaveGuessingHistory={$guessingHistory.mostRecentGuessDate !== undefined} {currentStreakLength} - correctGuessCount={$guessingHistory.correctDays.length} - incorrectGuessCount={$guessingHistory.incorrectDays.length} + correctGuessDays={$guessingHistory.correctDays} + incorrectGuessDays={$guessingHistory.incorrectDays} /> {/if} diff --git a/src/routes/sunrise-sunset/ScoreCardSection.svelte b/src/routes/sunrise-sunset/ScoreCardSection.svelte index df5f22b..cfc4a8b 100644 --- a/src/routes/sunrise-sunset/ScoreCardSection.svelte +++ b/src/routes/sunrise-sunset/ScoreCardSection.svelte @@ -1,10 +1,29 @@
@@ -12,17 +31,13 @@

Your Score Card

{#if doesUserHaveGuessingHistory}

- You've made {totalGuessCount} - {totalGuessCount === 1 ? "guess" : "guesses"} so far. -

-

- Your current streak is {currentStreakLength} - {currentStreakLength === 1 ? "day" : "days"}. -

-

- You've guessed correctly {Number(correctGuessCount / totalGuessCount) * - 100}% of the time. + {historyStatement}

+ + + {#if hasTextBeenCopied} +

Copied!

+ {/if} {:else}

You've not guessed yet.

{/if} @@ -54,4 +69,9 @@ .score__title { font-size: 1.2rem; } + + .score__text { + white-space: pre-line; + line-height: 135%; + } diff --git a/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.test.ts b/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.test.ts index 925b80d..36816e8 100644 --- a/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.test.ts +++ b/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.test.ts @@ -1,76 +1,150 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { SunriseSunsetStreakCalculator } from './SunriseSunsetStreakCalculator.js'; describe('SunriseSunsetStreakCalculator', () => { - const anyDay = '2023-01-29'; - it(`should recognise an empty streak`, () => { - // GIVEN - const correctDays = []; + describe(`Streak Length`, () => { + const anyDay = '2023-01-29'; + it(`should recognise an empty streak`, () => { + // GIVEN + const correctDays = []; - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(anyDay).getStreakLength(correctDays); + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(anyDay).getStreakLength(correctDays); - // THEN - expect(currentStreakLength).toBe(0); + // THEN + expect(currentStreakLength).toBe(0); + }); + + it(`should return a one-day streak when there is a gap between today and a previous streak`, () => { + // GIVEN + const correctDays = ['2023-01-29', '2023-01-27', '2023-01-26']; + const today = '2023-01-29'; + + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + + // THEN + expect(currentStreakLength).toBe(1); + }); + + it(`should return a two day stream if it's the most recent`, () => { + // GIVEN + const correctDays = ['2023-01-29', '2023-01-28', '2023-01-26', '2023-01-25', '2023-01-24']; + const today = '2023-01-29'; + + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + + // THEN + expect(currentStreakLength).toBe(2); + }); + + it(`should return recognise a one day streak`, () => { + // GIVEN + const correctDays = ['2023-01-28']; + const today = '2023-01-28'; + + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + + // THEN + expect(currentStreakLength).toBe(1); + }); + + it(`should recognise a two day streak`, () => { + // GIVEN + const correctDays = ['2023-01-28', '2023-01-27']; + const today = '2023-01-28'; + + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + + // THEN + expect(currentStreakLength).toBe(2); + }); + + it(`should recognise a three day streak`, () => { + // GIVEN + const correctDays = ['2023-01-28', '2023-01-27', '2023-01-26']; + const today = '2023-01-28'; + + // WHEN + const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + + // THEN + expect(currentStreakLength).toBe(3); + }); }); - it(`should return a one-day streak when there is a gap between today and a previous streak`, () => { - // GIVEN - const correctDays = ['2023-01-29', '2023-01-27', '2023-01-26']; - const today = '2023-01-29'; + describe(`Streak Visualisation`, () => { + let calculator: SunriseSunsetStreakCalculator; - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + beforeAll(() => { + calculator = new SunriseSunsetStreakCalculator('2023-01-29'); + }); - // THEN - expect(currentStreakLength).toBe(1); - }); + it(`Sound visualise an empty streak`, () => { + // GIVEN + const correctDays = []; + const incorrectDays = []; - it(`should return a two day stream if it's the most recent`, () => { - // GIVEN - const correctDays = ['2023-01-29', '2023-01-28', '2023-01-26', '2023-01-25', '2023-01-24']; - const today = '2023-01-29'; + // WHEN + const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrectDays); - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + // THEN + expect(emojiVisualisation).toBe(''); + }); - // THEN - expect(currentStreakLength).toBe(2); - }); + it(`should visualise a simple correct-incorrect two day streak`, () => { + // GIVEN + const correctDays = ['2023-01-20']; + const incorrectDays = ['2023-01-19']; - it(`should return recognise a one day streak`, () => { - // GIVEN - const correctDays = ['2023-01-28']; - const today = '2023-01-28'; + // WHEN + const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrectDays); - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + // THEN + expect(emojiVisualisation).toBe('šŸŒžšŸŒš'); + }); - // THEN - expect(currentStreakLength).toBe(1); - }); + it(`should handle a missing day`, () => { + // GIVEN + const correctDays = ['2023-01-20', '2023-01-21']; + const incorrect = ['2023-01-18', '2023-01-22']; - it(`should recognise a two day streak`, () => { - // GIVEN - const correctDays = ['2023-01-28', '2023-01-27']; - const today = '2023-01-28'; + // WHEN + const emojiVisualisation = calculator.getEmojiForHistory(correctDays, incorrect); - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + // THEN + expect(emojiVisualisation).toBe('šŸŒššŸŒžšŸŒžšŸ„·šŸŒš'); + }); - // THEN - expect(currentStreakLength).toBe(2); - }); + 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'); - it(`should recognise a three day streak`, () => { - // GIVEN - const correctDays = ['2023-01-28', '2023-01-27', '2023-01-26']; - const today = '2023-01-28'; + // WHEN + const shareableStatement = calculator.getShareableStatement(correctDays, incorrectDays, today); - // WHEN - const currentStreakLength = new SunriseSunsetStreakCalculator(today).getStreakLength(correctDays); + // THEN + const expected = `Sunrise, Sunset?\n2023-01-20\n\nCurrent Streak: 0\nLongest Streak: 0`; + expect(shareableStatement).toStrictEqual(expected); + }); - // THEN - expect(currentStreakLength).toBe(3); + 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); + }); }); }); diff --git a/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.ts b/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.ts index c77afdd..7d0ccf0 100644 --- a/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.ts +++ b/src/routes/sunrise-sunset/SunriseSunsetStreakCalculator.ts @@ -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', +} + +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 '🄷'; + } } - 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; + 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 daysAsDates = correctDays.map((day) => parse(day, 'yyyy-MM-dd', new Date())); - const sortedDays = daysAsDates.sort((a, b) => b.getTime() - a.getTime()); + const latestDate = this.sortedGuesses[0].day; + let guessesForMissingDays: SunriseSunsetDayGuess[] = []; - 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]); + let currentDay = earliestDate; - if (daysBetweenTodayAndMostRecentDay > 1) { - console.log(`Today is more than one day after the most recent correct day`); - return 1; + 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; } }