From 144f2621b932dbcb84d7d9b295231a415671ff23 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Thu, 2 May 2024 23:01:33 +1200 Subject: [PATCH 1/2] Child name: Fix typo and more concise display --- src/views/child.ts | 4 ++-- test/child.component.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/child.ts b/src/views/child.ts index aac1434..108a76b 100644 --- a/src/views/child.ts +++ b/src/views/child.ts @@ -56,7 +56,7 @@ const ChildComponent: m.Component> = { (dom as HTMLElement).querySelector('input')?.focus(); }, view({attrs: {state, actions}}) { - const name = state.name ?? 'Unnnamed'; + const name = state.name ?? 'Unnamed'; const age = state.age ? `(${formatAge(state.age)} old)` : ''; return m( @@ -70,7 +70,7 @@ const ChildComponent: m.Component> = { }, m( 'summary', - `Child ${state.idx + 1}: ${name} ${age}`, + `${name} ${age}`, m( 'a', { diff --git a/test/child.component.spec.ts b/test/child.component.spec.ts index df12191..a5a8718 100644 --- a/test/child.component.spec.ts +++ b/test/child.component.spec.ts @@ -47,7 +47,7 @@ o.spec('Child component', () => { // Summary out.should.have(1, 'summary'); - out.should.contain('Child 1: Ava'); + out.should.contain('Ava'); // DOB input out.should.have( From 31e96b97524c2f81ce69f52704a5bc69ac15c2a5 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Fri, 3 May 2024 00:33:55 +1200 Subject: [PATCH 2/2] Rewrite age display logic - Do not attempt to display age in weeks to avoid inaccuracies and odd edge cases. - Use Intl for pluralisation and list formatting. --- src/models/format.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ src/views/child.ts | 47 ++++-------------------------- test/format.spec.ts | 41 ++++++++++++++++++++++++++ tsconfig.json | 4 +-- 4 files changed, 117 insertions(+), 44 deletions(-) create mode 100644 src/models/format.ts create mode 100644 test/format.spec.ts diff --git a/src/models/format.ts b/src/models/format.ts new file mode 100644 index 0000000..f7a17ab --- /dev/null +++ b/src/models/format.ts @@ -0,0 +1,69 @@ +import {Period} from '@js-joda/core'; + +const listFormat = new Intl.ListFormat('en', { + style: 'long', + type: 'conjunction', +}); + +const pluralRules = new Intl.PluralRules('en'); + +const pluralSuffixes = new Map([ + ['one', ''], + ['other', 's'], +]); + +const pluralise = (word: string, n: number) => { + const rule = pluralRules.select(n); + const suffix = pluralSuffixes.get(rule); + return `${word}${suffix}`; +}; + +export const formatAge = (period: Period) => { + const parts = []; + + const years = period.years(); + const months = period.months(); + const days = period.days(); + + // Special dates + if (period.isNegative()) { + // not hatched yet + return '🥚'; + } else if (period.isZero()) { + // welcome! + return '🐣'; + } + + if (period.years() >= 66_000_000 && period.years() <= 72_700_000) { + return '🦖'; + } + + if (period.years() >= 4_500_000_000) { + return '🌌'; + } + + // Always display years + if (years > 0) { + parts.push(`${years} ${pluralise('year', years)}`); + } + + // Display months up to 20 years + if (months > 0 && years < 20) { + parts.push(`${months} ${pluralise('month', months)}`); + } + + // Display days up to 3 months + if (days > 0 && years < 1 && months < 3) { + parts.push(`${days} ${pluralise('day', days)}`); + } + + const formattedAge = listFormat.format(parts); + + // Birthday + if (period.months() === 0 && period.days() === 0) { + // birthday! + return formattedAge + ' 🎈'; + } else { + return formattedAge; + } +}; diff --git a/src/views/child.ts b/src/views/child.ts index 108a76b..27e3e31 100644 --- a/src/views/child.ts +++ b/src/views/child.ts @@ -10,46 +10,7 @@ import { Sex, } from '../models/state'; import {LocalDate, Period, convert} from '@js-joda/core'; - -const formatAge = (period: Period) => { - const parts = []; - - const years = period.years(); - const months = period.months(); - // TODO: week approximation problem - const weeks = ~~(period.days() / 7); - const days = period.days() % 7; - - if (years > 0) { - parts.push(`${years} year${years > 1 ? 's' : ''}`); - } - - if (years < 2) { - if (months > 0) { - parts.push(`${months} month${months > 1 ? 's' : ''}`); - } - - if (years < 1) { - if (months < 3 && weeks > 0) { - parts.push(`${weeks} week${weeks > 1 ? 's' : ''}`); - } - - if (months < 3) { - if (weeks < 12 && days > 0) { - parts.push(`${days} day${days > 1 ? 's' : ''}`); - } - } - } - } - - if (period.isNegative()) { - parts.push('🥚'); - } else if (!period.isZero() && period.months() === 0 && period.days() === 0) { - parts.push('🎈'); - } - - return parts.length === 0 ? '🐣' : parts.join(', '); -}; +import {formatAge} from '../models/format'; const ChildComponent: m.Component> = { oncreate({dom}) { @@ -57,7 +18,9 @@ const ChildComponent: m.Component> = { }, view({attrs: {state, actions}}) { const name = state.name ?? 'Unnamed'; - const age = state.age ? `(${formatAge(state.age)} old)` : ''; + const summary = `${name}${ + state.age ? `, ${formatAge(state.age)} old` : '' + }`; return m( 'details', @@ -70,7 +33,7 @@ const ChildComponent: m.Component> = { }, m( 'summary', - `${name} ${age}`, + summary, m( 'a', { diff --git a/test/format.spec.ts b/test/format.spec.ts new file mode 100644 index 0000000..2ceb936 --- /dev/null +++ b/test/format.spec.ts @@ -0,0 +1,41 @@ +import o from 'ospec'; +import {formatAge} from '../src/models/format'; +import {Period} from '@js-joda/core'; + +o.spec('Format age', () => { + const testCases: [Period, string][] = [ + [Period.ofYears(-1), '🥚'], + [Period.ofMonths(-1), '🥚'], + [Period.ofDays(-1), '🥚'], + [Period.ZERO, '🐣'], + [Period.ofDays(1), '1 day'], + [Period.ofDays(2), '2 days'], + [Period.ofDays(31), '31 days'], + [Period.ofWeeks(1), '7 days'], + [Period.ofWeeks(1).plusDays(1), '8 days'], + [Period.ofMonths(1), '1 month'], + [Period.ofMonths(1).plusDays(1), '1 month and 1 day'], + [Period.ofMonths(2), '2 months'], + [Period.ofMonths(2).plusDays(2), '2 months and 2 days'], + [Period.ofMonths(3), '3 months'], + // only display days up to 3 months + [Period.ofMonths(3).plusDays(1), '3 months'], + [Period.ofYears(1), '1 year 🎈'], + [Period.ofYears(1).plusMonths(1), '1 year and 1 month'], + [Period.ofYears(1).plusMonths(1).plusDays(1), '1 year and 1 month'], + [Period.ofYears(2), '2 years 🎈'], + [Period.ofYears(19).plusMonths(11), '19 years and 11 months'], + // only display months up to 20 years + [Period.ofYears(20), '20 years 🎈'], + [Period.ofYears(20).plusMonths(1), '20 years'], + // really old + [Period.ofYears(66_000_000), '🦖'], + [Period.ofYears(4_500_000_000), '🌌'], + ]; + + testCases.forEach(([age, expectedFormat]) => { + o(age.toString(), () => { + o(formatAge(age)).equals(expectedFormat); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 100780e..e188986 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,10 +5,10 @@ "outDir": "build", "noImplicitAny": true, "lib": [ - "es2020" + "es2021" ], "module": "es6", - "target": "es2020", + "target": "es2021", "moduleResolution": "node", "allowSyntheticDefaultImports": true, },