Implement age calculation for death event (#80)

This commit is contained in:
czifumasa 2022-01-13 17:19:40 +01:00 committed by GitHub
parent 6b030a1ccc
commit 0b8084e3bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 474 additions and 26 deletions

View File

@ -1,5 +1,6 @@
import flatMap from 'array.prototype.flatmap';
import {compareDates, translateDate} from '../util/date_util';
import {calcAge} from '../util/age_util';
import {DateOrRange, getDate} from 'topola';
import {dereference, GedcomData, getData} from '../util/gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
@ -80,6 +81,31 @@ function eventFamilyDetails(
return null;
}
function eventAdditionalDetails(
eventEntry: GedcomEntry,
indi: string,
gedcom: GedcomData,
intl: IntlShape,
) {
if (eventEntry.tag === 'DEAT') {
const deathDate = resolveDate(eventEntry);
const birthDate = gedcom.indis[indi].tree
.filter((indiSubEntry) => indiSubEntry.tag === 'BIRT')
.map((birthEvent) => resolveDate(birthEvent))
.find((topolaDate) => topolaDate);
if (birthDate && deathDate) {
return (
<div className="meta">
{calcAge(birthDate?.data, deathDate?.data, intl)}
</div>
);
}
}
return null;
}
function eventPlace(entry: GedcomEntry) {
const place = entry.tree.find((subEntry) => subEntry.tag === 'PLAC');
if (place && place.data) {
@ -151,7 +177,7 @@ function toIndiEvent(
date: date ? getDate(date.data) : undefined,
type: entry.tag,
header: eventHeader(entry.tag, date, intl),
subHeader: null,
subHeader: eventAdditionalDetails(entry, indi, gedcom, intl),
place: eventPlace(entry),
notes: eventNotes(entry, gedcom),
},

View File

@ -65,6 +65,10 @@
"date.after": "po {from}",
"date.before": "przed {to}",
"error.error": "Błąd",
"age.exact": "{age, plural, =0 {Mniej niż 1 rok} one {{qualifier} 1 rok} many {{qualifier} # lat} other {{qualifier} # lata}}",
"age.less": "Mniej niż {age, plural, =0 {1 rok} one {1 rok} many {# lat} other {# lata}}",
"age.more": "Więcej niż {age, plural, =0 {0 lat} one {1 rok} many {# lat} other {# lata}}",
"age.between": "Między {ageFrom} a {ageTo, plural, =0 {0 lat} one {1 rok} many {# lat} other {# lata}}",
"error.failed_pdf": "Nie udało się utworzyć pliku PDF. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.",
"error.failed_png": "Nie udało się utworzyć pliku PNG. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.",
"error.failed_to_load_file": "Błąd wczytywania pliku",

120
src/util/age_util.spec.ts Normal file
View File

@ -0,0 +1,120 @@
import expect from 'expect';
import {createIntl} from 'react-intl';
import {calcAge} from './age_util';
const intl = createIntl({
locale: 'en',
messages: {},
});
describe('calcAge()', () => {
it('age 1 year', () => {
const age = calcAge('1999', '2000', intl);
expect(age).toEqual('1 year');
});
it('age multiple years', () => {
const age = calcAge('1999', '2003', intl);
expect(age).toEqual('4 years');
});
it('0 years as Less than 1 year', () => {
const age = calcAge('1 Sep 1990', '1 Oct 1990', intl);
expect(age).toEqual('Less than 1 year');
});
it('age with qualifier', () => {
const age = calcAge('ABT 1990', '2021', intl);
expect(age).toEqual('about 31 years');
});
it('age for full dates', () => {
const age = calcAge('1 Sep 1990', '1 Sep 2021', intl);
expect(age).toEqual('31 years');
});
it('age with round down respecting leap years', () => {
const age = calcAge('2 Sep 1990', '1 Sep 2021', intl);
expect(age).toEqual('30 years');
});
it('age with exact and range between', () => {
const age = calcAge('1990', 'BET 2020 AND 2021', intl);
expect(age).toEqual('Between 30 and 31 years');
});
it('age with exact and range after', () => {
const age = calcAge('1990', 'AFT 2021', intl);
expect(age).toEqual('More than 31 years');
});
it('age with exact and range before', () => {
const age = calcAge('1990', 'BEF 2021', intl);
expect(age).toEqual('Less than 31 years');
});
it('age with range between and exact', () => {
const age = calcAge('BET 1990 AND 1991', '2021', intl);
expect(age).toEqual('Between 30 and 31 years');
});
it('age with 2 ranges between', () => {
const age = calcAge('BET 1990 AND 1991', 'BET 2020 AND 2021', intl);
expect(age).toEqual('Between 29 and 31 years');
});
it('age with range between and range after', () => {
const age = calcAge('BET 1990 AND 1991', 'AFT 2021', intl);
expect(age).toEqual('More than 30 years');
});
it('age with range between and range before', () => {
const age = calcAge('BET 1990 AND 1991', 'BEF 2021', intl);
expect(age).toEqual('Less than 31 years');
});
it('age with range after and exact', () => {
const age = calcAge('AFT 1990', '2021', intl);
expect(age).toEqual('Less than 31 years');
});
it('age with range after and range between', () => {
const age = calcAge('AFT 1990', 'BET 2020 AND 2021', intl);
expect(age).toEqual('Less than 31 years');
});
it('age with range after and before', () => {
const age = calcAge('AFT 1990', 'BEF 2021', intl);
expect(age).toEqual('Less than 31 years');
});
it('age with 2 ranges after cannot be calculated', () => {
const age = calcAge('AFT 1990', 'AFT 2021', intl);
expect(age).toBeUndefined();
});
it('age with range before and exact', () => {
const age = calcAge('BEF 1990', '2021', intl);
expect(age).toEqual('More than 31 years');
});
it('age with ranges before and between', () => {
const age = calcAge('BEF 1990', 'BET 2020 AND 2021', intl);
expect(age).toEqual('More than 30 years');
});
it('age with ranges before and after', () => {
const age = calcAge('BEF 1990', 'AFT 2021', intl);
expect(age).toEqual('More than 31 years');
});
it('age with 2 ranges before cannot be calculated', () => {
const age = calcAge('BEF 1990', 'BEF 2021', intl);
expect(age).toBeUndefined();
});
it('age with death before birth cannot be calculated', () => {
const age = calcAge('2021', '1990', intl);
expect(age).toBeUndefined();
});
it('age with overlapping between ranges cannot be calculated', () => {
const age = calcAge('BET 1990 AND 2000', 'BET 1999 AND 2021 ', intl);
expect(age).toBeUndefined();
});
it('age with invalid between range cannot be calculated', () => {
const age = calcAge('BET 1999 AND 1990', ' 2021 ', intl);
expect(age).toBeUndefined();
});
it('age without birth cannot be calculated', () => {
const age = calcAge('', '2021', intl);
expect(age).toBeUndefined();
});
it('age without death cannot be calculated', () => {
const age = calcAge('1990', '', intl);
expect(age).toBeUndefined();
});
});

242
src/util/age_util.ts Normal file
View File

@ -0,0 +1,242 @@
import {Date as TopolaDate} from 'topola/dist/data';
import {IntlShape} from 'react-intl';
import {DateOrRange, getDate} from 'topola';
import {
areDateRangesOverlapped,
compareDates,
formatDateQualifier,
isDateRangeClosed,
isValidDateOrRange,
toDateObject,
} from './date_util';
function formatExactAge(
birthDate: TopolaDate,
deathDate: TopolaDate,
intl: IntlShape,
): string {
const ageInYears = calcDateDifferenceInYears(birthDate, deathDate);
const qualifier = birthDate.qualifier || deathDate.qualifier;
const translatedQualifier =
qualifier && formatDateQualifier(qualifier, intl) + ' ';
return intl.formatMessage(
{
id: 'age.exact',
defaultMessage:
'{qualifier}{age, plural, =0 {Less than 1 year} one {1 year} other {# years}}',
},
{age: ageInYears, qualifier: translatedQualifier},
);
}
function formatAgeMoreThan(
birthDate: TopolaDate,
deathDate: TopolaDate,
intl: IntlShape,
): string {
const ageInYears = calcDateDifferenceInYears(birthDate, deathDate);
return intl.formatMessage(
{
id: 'age.more',
defaultMessage:
'More than {age, plural, =0 {0 years} one {1 year} other {# years}}',
},
{age: ageInYears},
);
}
function formatAgeLessThan(
birthDate: TopolaDate,
deathDate: TopolaDate,
intl: IntlShape,
): string {
const ageInYears = calcDateDifferenceInYears(birthDate, deathDate);
return intl.formatMessage(
{
id: 'age.less',
defaultMessage:
'Less than {age, plural, =0 {1 year} one {1 year} other {# years}}',
},
{age: ageInYears},
);
}
function formatAgeBetween(
birthDateFrom: TopolaDate,
birthDateTo: TopolaDate,
deathDateFrom: TopolaDate,
deathDateTo: TopolaDate,
intl: IntlShape,
): string {
const ageInYearsFrom = calcDateDifferenceInYears(birthDateTo, deathDateFrom);
const ageInYearsTo = calcDateDifferenceInYears(birthDateFrom, deathDateTo);
return intl.formatMessage(
{
id: 'age.between',
defaultMessage:
'Between {ageFrom} and {ageTo, plural, =0 {0 years} one {1 year} other {# years}}',
},
{ageFrom: ageInYearsFrom, ageTo: ageInYearsTo},
);
}
function canCalculateAge(
birthDate: DateOrRange | undefined,
deathDate: DateOrRange | undefined,
): boolean {
if (birthDate && deathDate) {
// cannot calculate if there is no valid birth or death date
if (!isValidDateOrRange(birthDate) || !isValidDateOrRange(deathDate)) {
return false;
}
//cannot calculate if death date is before birth date
if (compareDates(birthDate, deathDate) > 0) {
return false;
}
// cannot calculate if closed date range for birth or death are overlapping
if (
birthDate.dateRange &&
deathDate.dateRange &&
isDateRangeClosed(birthDate?.dateRange) &&
isDateRangeClosed(deathDate?.dateRange)
) {
return !areDateRangesOverlapped(birthDate.dateRange, deathDate.dateRange);
}
return true;
}
return false;
}
function calcDateDifferenceInYears(
firstDate: TopolaDate,
secondDate: TopolaDate,
): number {
const firstDateObject = toDateObject(firstDate);
const secondDateObject = toDateObject(secondDate);
const dateDiff = new Date(
secondDateObject.valueOf() - firstDateObject.valueOf(),
);
return Math.abs(dateDiff.getUTCFullYear() - 1970);
}
export function calcAge(
birthGedcomDate: string | undefined,
deathGedcomDate: string | undefined,
intl: IntlShape,
): string | undefined {
if (birthGedcomDate && deathGedcomDate) {
const birthDateOrRange = getDate(birthGedcomDate);
const deathDateOrRange = getDate(deathGedcomDate);
if (canCalculateAge(birthDateOrRange, deathDateOrRange)) {
if (birthDateOrRange?.date) {
if (deathDateOrRange?.date) {
return formatExactAge(
birthDateOrRange.date,
deathDateOrRange.date,
intl,
);
}
if (
deathDateOrRange?.dateRange?.from &&
deathDateOrRange.dateRange?.to
) {
return formatAgeBetween(
birthDateOrRange.date,
birthDateOrRange.date,
deathDateOrRange?.dateRange?.from,
deathDateOrRange?.dateRange?.to,
intl,
);
}
if (deathDateOrRange?.dateRange?.from) {
return formatAgeMoreThan(
birthDateOrRange.date,
deathDateOrRange.dateRange?.from,
intl,
);
}
if (deathDateOrRange?.dateRange?.to) {
return formatAgeLessThan(
birthDateOrRange.date,
deathDateOrRange.dateRange?.to,
intl,
);
}
}
if (
birthDateOrRange?.dateRange?.from &&
birthDateOrRange?.dateRange?.to
) {
if (deathDateOrRange?.date) {
return formatAgeBetween(
birthDateOrRange?.dateRange?.from,
birthDateOrRange?.dateRange?.to,
deathDateOrRange?.date,
deathDateOrRange?.date,
intl,
);
}
if (
deathDateOrRange?.dateRange?.from &&
deathDateOrRange.dateRange?.to
) {
return formatAgeBetween(
birthDateOrRange?.dateRange?.from,
birthDateOrRange?.dateRange?.to,
deathDateOrRange?.dateRange?.from,
deathDateOrRange?.dateRange?.to,
intl,
);
}
if (deathDateOrRange?.dateRange?.from) {
return formatAgeMoreThan(
birthDateOrRange.dateRange?.to,
deathDateOrRange.dateRange?.from,
intl,
);
}
if (deathDateOrRange?.dateRange?.to) {
return formatAgeLessThan(
birthDateOrRange.dateRange?.from,
deathDateOrRange.dateRange?.to,
intl,
);
}
}
if (birthDateOrRange?.dateRange?.from) {
if (deathDateOrRange?.date) {
return formatAgeLessThan(
birthDateOrRange.dateRange?.from,
deathDateOrRange.date,
intl,
);
}
if (deathDateOrRange?.dateRange?.to) {
return formatAgeLessThan(
birthDateOrRange.dateRange?.from,
deathDateOrRange.dateRange?.to,
intl,
);
}
}
if (birthDateOrRange?.dateRange?.to) {
if (deathDateOrRange?.date) {
return formatAgeMoreThan(
birthDateOrRange?.dateRange?.to,
deathDateOrRange.date,
intl,
);
}
if (deathDateOrRange?.dateRange?.from) {
return formatAgeMoreThan(
birthDateOrRange?.dateRange?.to,
deathDateOrRange.dateRange?.from,
intl,
);
}
}
}
}
}

View File

@ -14,19 +14,8 @@ function formatDate(date: TopolaDate, intl: IntlShape) {
if (!hasDay && !hasMonth && !hasYear) {
return date.text || '';
}
const dateObject = new Date(
hasYear ? date.year! : 0,
hasMonth ? date.month! - 1 : 0,
hasDay ? date.day! : 1,
);
const qualifier = date.qualifier && date.qualifier.toLowerCase();
const translatedQualifier =
qualifier &&
intl.formatMessage({
id: `date.${qualifier}`,
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
});
const dateObject = toDateObject(date);
const translatedQualifier = formatDateQualifier(date.qualifier, intl);
const formatOptions: Intl.DateTimeFormatOptions = {
day: hasDay ? 'numeric' : undefined,
@ -76,6 +65,22 @@ function formatDateRage(dateRange: DateRange, intl: IntlShape) {
return '';
}
export function formatDateQualifier(
qualifier: string | undefined,
intl: IntlShape,
): string {
const lowerCaseQualifier = qualifier && qualifier.toLowerCase();
return (
(lowerCaseQualifier &&
intl.formatMessage({
id: `date.${lowerCaseQualifier}`,
defaultMessage:
DATE_QUALIFIERS.get(lowerCaseQualifier) || lowerCaseQualifier,
})) ||
''
);
}
/** Formats a DateOrRange object. */
export function formatDateOrRange(
dateOrRange: DateOrRange | undefined,
@ -98,19 +103,10 @@ export function translateDate(gedcomDate: string, intl: IntlShape): string {
return formatDateOrRange(getDate(gedcomDate), intl);
}
/** Compares a dates given in GEDCOM format. */
export function compareDates(
firstDateOrRange: DateOrRange | undefined,
secondDateOrRange: DateOrRange | undefined,
export function compareTopolaDates(
date1: TopolaDate | undefined,
date2: TopolaDate | undefined,
): number {
const date1 =
firstDateOrRange &&
(firstDateOrRange.date ||
(firstDateOrRange.dateRange && firstDateOrRange.dateRange.from));
const date2 =
secondDateOrRange &&
(secondDateOrRange.date ||
(secondDateOrRange.dateRange && secondDateOrRange.dateRange.from));
if (!date1 || !date1.year || !date2 || !date2.year) {
return 0;
}
@ -128,3 +124,63 @@ export function compareDates(
}
return 0;
}
/** Compares a dates given in GEDCOM format. */
export function compareDates(
firstDateOrRange: DateOrRange | undefined,
secondDateOrRange: DateOrRange | undefined,
): number {
const date1 =
firstDateOrRange &&
(firstDateOrRange.date ||
(firstDateOrRange.dateRange &&
(firstDateOrRange.dateRange.from || firstDateOrRange.dateRange.to)));
const date2 =
secondDateOrRange &&
(secondDateOrRange.date ||
(secondDateOrRange.dateRange &&
(secondDateOrRange.dateRange.from || secondDateOrRange.dateRange.to)));
return compareTopolaDates(date1, date2);
}
export function areDateRangesOverlapped(
range1: DateRange,
range2: DateRange,
): boolean {
return (
compareTopolaDates(range1.from, range2.to) <= 0 &&
compareTopolaDates(range1.to, range2.from) >= 0
);
}
export function isValidDateOrRange(
dateOrRange: DateOrRange | undefined,
): boolean {
// invalid when range is closed and start is before end
if (isDateRangeClosed(dateOrRange?.dateRange)) {
return (
compareTopolaDates(
dateOrRange?.dateRange?.from,
dateOrRange?.dateRange?.to,
) <= 0
);
}
//valid when there is exact date or date range has start or end defined
return !!(
dateOrRange?.date ||
dateOrRange?.dateRange?.from ||
dateOrRange?.dateRange?.to
);
}
export function isDateRangeClosed(range: DateRange | undefined): boolean {
return !!(range?.from && range?.to);
}
export function toDateObject(date: TopolaDate): Date {
return new Date(
date.year !== undefined ? date.year! : 0,
date.month !== undefined ? date.month! - 1 : 0,
date.day !== undefined ? date.day! : 1,
);
}