« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json38
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js41
-rw-r--r--src/content/dependencies/generateColorStyleRules.js7
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js51
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js28
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js112
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js58
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js70
-rw-r--r--src/data/things/language.js108
-rw-r--r--src/static/client3.js53
-rw-r--r--src/static/site6.css67
-rw-r--r--src/strings-default.yaml71
13 files changed, 615 insertions, 90 deletions
diff --git a/package-lock.json b/package-lock.json
index bca46bd5..1da6e9ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
             "version": "0.1.0",
             "license": "GPL-3.0",
             "dependencies": {
+                "@js-temporal/polyfill": "^0.4.4",
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
                 "eslint": "^8.37.0",
@@ -241,6 +242,18 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "node_modules/@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "dependencies": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3077,6 +3090,11 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
+        "node_modules/jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
         "node_modules/json-parse-even-better-errors": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz",
@@ -5231,8 +5249,7 @@
         "node_modules/tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-            "dev": true
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "node_modules/tuf-js": {
             "version": "2.1.0",
@@ -5878,6 +5895,15 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "requires": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            }
+        },
         "@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7928,6 +7954,11 @@
                 "argparse": "^2.0.1"
             }
         },
+        "jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
         "json-parse-even-better-errors": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz",
@@ -9473,8 +9504,7 @@
         "tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-            "dev": true
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "tuf-js": {
             "version": "2.1.0",
diff --git a/package.json b/package.json
index 7cd9e0ef..a1c165bc 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
         "node": ">= 20.9.0"
     },
     "dependencies": {
+        "@js-temporal/polyfill": "^0.4.4",
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
         "eslint": "^8.37.0",
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
new file mode 100644
index 00000000..63acecf2
--- /dev/null
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -0,0 +1,41 @@
+export default {
+  contentDependencies: ['generateDatetimestampTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  data: (date) =>
+    ({date}),
+
+  relations: (relation) =>
+    ({template: relation('generateDatetimestampTemplate')}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    // Only has an effect for 'year' style.
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.date)
+       : slots.style === 'year'
+          ? data.date.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+        slots.style === 'year' &&
+          language.formatDate(data.date),
+
+      datetime:
+        data.date.toISOString(),
+    }),
+};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
index 1b316a3c..3f1d0130 100644
--- a/src/content/dependencies/generateColorStyleRules.js
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -18,9 +18,12 @@ export default {
       `:root {`,
       ...(
         relations.variables
-          .slot('color', slots.color)
+          .slots({
+            color: slots.color,
+            context: 'page-root',
+            mode: 'property-list',
+          })
           .content
-          .split(';')
           .map(line => line + ';')),
       `}`,
     ].join('\n');
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index f30d786b..7cd04bd1 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -2,7 +2,23 @@ export default {
   extraDependencies: ['html', 'getColors'],
 
   slots: {
-    color: {validate: v => v.isColor},
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'page-root',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+
+    mode: {
+      validate: v => v.is('style', 'property-list'),
+      default: 'style',
+    },
   },
 
   generate(slots, {getColors}) {
@@ -18,7 +34,7 @@ export default {
       shadow,
     } = getColors(slots.color);
 
-    return [
+    let anyContent = [
       `--primary-color: ${primary}`,
       `--dark-color: ${dark}`,
       `--dim-color: ${dim}`,
@@ -26,6 +42,35 @@ export default {
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
-    ].join('; ');
+    ];
+
+    let selectedProperties;
+
+    switch (slots.context) {
+      case 'any-content':
+        selectedProperties = anyContent;
+        break;
+
+      case 'page-root':
+        selectedProperties = [
+          ...anyContent,
+          `--page-primary-color: ${primary}`,
+        ];
+        break;
+
+      case 'primary-only':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+        ];
+        break;
+    }
+
+    switch (slots.mode) {
+      case 'style':
+        return selectedProperties.join('; ');
+
+      case 'property-list':
+        return selectedProperties;
+    }
   },
 };
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
new file mode 100644
index 00000000..bfba647f
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    mainContent: {type: 'html'},
+    tooltipContent: {type: 'html'},
+    datetime: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {
+      [html.joinChildren]: '',
+
+      class: [
+        'datetimestamp',
+        slots.tooltipContent && 'has-tooltip',
+      ],
+    }, [
+      html.tag('time',
+        {datetime: slots.datetime},
+        slots.mainContent),
+
+      slots.tooltipContent &&
+        html.tag('span', {class: 'datetimestamp-tooltip'},
+          html.tag('span', {class: 'datetimestamp-tooltip-content'},
+            slots.tooltipContent)),
+    ]),
+};
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 05df33fb..0e5d645b 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,7 +1,9 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleVariables',
     'generateContentHeading',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
@@ -62,18 +64,27 @@ export default {
       sec.albums.galleryLink =
         relation('linkGroupGallery', group);
 
-      sec.albums.entries =
-        group.albums.map(album => {
-          const links = {};
-          links.albumLink = relation('linkAlbum', album);
+      sec.albums.colorVariables =
+        group.albums
+          .map(() => relation('generateColorStyleVariables'));
 
-          const otherGroup = album.groups.find(g => g !== group);
-          if (otherGroup) {
-            links.groupLink = relation('linkGroup', otherGroup);
-          }
+      sec.albums.albumLinks =
+        group.albums
+          .map(album => relation('linkAlbum', album));
 
-          return links;
-        });
+      sec.albums.groupLinks =
+        group.albums
+          .map(album => album.groups.find(g => g !== group))
+          .map(group =>
+            (group
+              ? relation('linkGroup', group)
+              : null));
+
+      sec.albums.datetimestamps =
+        group.albums.map(album =>
+          (album.date
+            ? relation('generateAbsoluteDatetimestamp', album.date)
+            : null));
     }
 
     return relations;
@@ -85,11 +96,8 @@ export default {
     data.name = group.name;
     data.color = group.color;
 
-    if (!empty(group.albums)) {
-      data.albumYears =
-        group.albums
-          .map(album => album.date?.getFullYear());
-    }
+    data.albumColors =
+      group.albums.map(album => album.color);
 
     return data;
   },
@@ -133,34 +141,50 @@ export default {
               })),
 
             html.tag('ul',
-              sec.albums.entries.map(({albumLink, groupLink}, index) => {
-                // All these strings are really jank, and should probably
-                // be implemented with the same 'const parts = [], opts = {}'
-                // form used elsewhere...
-                const year = data.albumYears[index];
-                const item =
-                  (year
-                    ? language.$('groupInfoPage.albumList.item', {
-                        year,
-                        album: albumLink,
-                      })
-                    : language.$('groupInfoPage.albumList.item.withoutYear', {
-                        album: albumLink,
-                      }));
-
-                return html.tag('li',
-                  (groupLink
-                    ? language.$('groupInfoPage.albumList.item.withAccent', {
-                        item,
-                        accent:
-                          html.tag('span', {class: 'other-group-accent'},
-                            language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                              group:
-                                groupLink.slot('color', false),
-                            })),
-                      })
-                    : item));
-              })),
+              stitchArrays({
+                albumLink: sec.albums.albumLinks,
+                groupLink: sec.albums.groupLinks,
+                datetimestamp: sec.albums.datetimestamps,
+                colorVariables: sec.albums.colorVariables,
+                albumColor: data.albumColors,
+              }).map(({
+                  albumLink,
+                  groupLink,
+                  datetimestamp,
+                  colorVariables,
+                  albumColor,
+                }) => {
+                  const prefix = 'groupInfoPage.albumList.item';
+                  const parts = [prefix];
+                  const options = {};
+
+                  options.album =
+                    albumLink.slot('color', false);
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.yearAccent =
+                      language.$(prefix, 'yearAccent', {
+                        year:
+                          datetimestamp.slots({style: 'year', tooltip: true}),
+                      });
+                  }
+
+                  if (groupLink) {
+                    parts.push('withOtherGroup');
+                    options.otherGroupAccent =
+                      html.tag('span', {class: 'other-group-accent'},
+                        language.$(prefix, 'otherGroupAccent', {
+                          group:
+                            groupLink.slot('color', false),
+                        }));
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', albumColor).content},
+                      language.$(...parts, options)));
+                })),
           ],
         ],
 
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 00000000..bbe33188
--- /dev/null
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateDatetimestampTemplate',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (currentDate, referenceDate) =>
+    (currentDate.getTime() === referenceDate.getTime()
+      ? {equal: true, date: currentDate}
+      : {equal: false, currentDate, referenceDate}),
+
+  relations: (relation, currentDate) =>
+    ({template: relation('generateDatetimestampTemplate'),
+      fallback: relation('generateAbsoluteDatetimestamp', currentDate)}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (data.comparison === 'equal') {
+      return relations.fallback.slots({
+        style: slots.style,
+        tooltip: slots.tooltip,
+      });
+    }
+
+    return relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.currentDate)
+       : slots.style === 'year'
+          ? data.currentDate.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+          language.formatRelativeDate(data.currentDate, data.referenceDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: slots.style === 'year',
+          }),
+
+      datetime:
+        data.currentDate.toISOString(),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index d8908ade..041f6bbc 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,20 +1,23 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 import {sortAlbumsTracksChronologically, sortFlashesChronologically} from '#wiki-data';
 
 import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
     'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
+    'generateColorStyleVariables',
     'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generatePageLayout',
+    'generateRelativeDatetimestamp',
     'generateTrackAdditionalNamesBox',
     'generateTrackCoverArtwork',
     'generateTrackList',
@@ -139,6 +142,29 @@ export default {
       otherReleases.heading =
         relation('generateContentHeading');
 
+      otherReleases.colorVariables =
+        track.otherReleases
+          .map(() => relation('generateColorStyleVariables'));
+
+      otherReleases.trackLinks =
+        track.otherReleases
+          .map(track => relation('linkTrack', track));
+
+      otherReleases.albumLinks =
+        track.otherReleases
+          .map(track => relation('linkAlbum', track.album));
+
+      otherReleases.datetimestamps =
+        track.otherReleases.map(track2 =>
+          (track2.date
+            ? (track.date
+                ? relation('generateRelativeDatetimestamp',
+                    track2.date,
+                    track.date)
+                : relation('generateAbsoluteDatetimestamp',
+                    track2.date))
+            : null));
+
       otherReleases.items =
         track.otherReleases.map(track => ({
           trackLink: relation('linkTrack', track),
@@ -289,6 +315,9 @@ export default {
       hasTrackNumbers: track.album.hasTrackNumbers,
       trackNumber: track.album.tracks.indexOf(track) + 1,
 
+      otherReleaseColors:
+        track.otherReleases.map(track => track.color),
+
       numAdditionalFiles: track.additionalFiles.length,
     };
   },
@@ -355,12 +384,39 @@ export default {
               }),
 
             html.tag('ul',
-              sec.otherReleases.items.map(({trackLink, albumLink}) =>
-                html.tag('li',
-                  language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: trackLink,
-                    album: albumLink,
-                  })))),
+              stitchArrays({
+                trackLink: sec.otherReleases.trackLinks,
+                albumLink: sec.otherReleases.albumLinks,
+                datetimestamp: sec.otherReleases.datetimestamps,
+                colorVariables: sec.otherReleases.colorVariables,
+                color: data.otherReleaseColors,
+              }).map(({
+                  trackLink,
+                  albumLink,
+                  datetimestamp,
+                  colorVariables,
+                  color,
+                }) => {
+                  const parts = ['releaseInfo.alsoReleasedAs.item'];
+                  const options = {};
+
+                  options.track = trackLink.slot('color', false);
+                  options.album = albumLink;
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.year =
+                      datetimestamp.slots({
+                        style: 'year',
+                        tooltip: true,
+                      });
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', color).content},
+                      language.$(...parts, options)));
+                })),
           ],
 
           sec.contributors && [
diff --git a/src/data/things/language.js b/src/data/things/language.js
index d8af9620..80a34575 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,3 +1,5 @@
+import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+
 import {isLanguageCode} from '#validators';
 import {Tag} from '#html';
 
@@ -284,6 +286,108 @@ export class Language extends Thing {
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    days: numDays = 0,
+    approximate = false,
+  }) {
+    let basis;
+
+    const years = this.countYears(numYears, {unit: true});
+    const months = this.countMonths(numMonths, {unit: true});
+    const days = this.countDays(numDays, {unit: true});
+
+    if (numYears && numMonths && numDays)
+      basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days});
+    else if (numYears && numMonths)
+      basis = this.formatString('count.dateDuration.yearsMonths', {years, months});
+    else if (numYears && numDays)
+      basis = this.formatString('count.dateDuration.yearsDays', {years, days});
+    else if (numYears)
+      basis = this.formatString('count.dateDuration.years', {years});
+    else if (numMonths && numDays)
+      basis = this.formatString('count.dateDuration.monthsDays', {months, days});
+    else if (numMonths)
+      basis = this.formatzString('count.dateDuration.months', {months});
+    else if (numDays)
+      basis = this.formatString('count.dateDuration.days', {days});
+    else
+      return this.formatString('count.dateDuration.zero');
+
+    if (approximate) {
+      return this.formatString('count.dateDuration.approximate', {
+        duration: basis,
+      });
+    } else {
+      return basis;
+    }
+  }
+
+  formatRelativeDate(currentDate, referenceDate, {
+    considerRoundingDays = false,
+    approximate = true,
+    absolute = true,
+  } = {}) {
+    const currentInstant = toTemporalInstant.apply(currentDate);
+    const referenceInstant = toTemporalInstant.apply(referenceDate);
+
+    const comparison =
+      Temporal.Instant.compare(currentInstant, referenceInstant);
+
+    if (comparison === 0) {
+      return this.formatString('count.dateDuration.same');
+    }
+
+    const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC');
+    const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC');
+
+    const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ);
+    const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ);
+
+    const {years, months, days} =
+      laterTDZ.since(earlierTDZ, {
+        largestUnit: 'year',
+        smallestUnit:
+          (considerRoundingDays
+            ? (laterTDZ.since(earlierTDZ, {
+                largestUnit: 'year',
+                smallestUnit: 'day',
+              }).years
+                ? 'month'
+                : 'day')
+            : 'day'),
+        roundingMode: 'halfCeil',
+      });
+
+    const duration =
+      this.formatDateDuration({
+        years, months, days,
+        approximate: false,
+      });
+
+    const relative =
+      this.formatString(
+        'count.dateDuration',
+        (approximate && (years || months || days)
+          ? (comparison === -1
+              ? 'approximateEarlier'
+              : 'approximateLater')
+          : (comparison === -1
+              ? 'earlier'
+              : 'later')),
+        {duration});
+
+    if (absolute) {
+      return this.formatString('count.dateDuration.relativeAbsolute', {
+        relative,
+        absolute: this.formatDate(currentDate),
+      });
+    } else {
+      return relative;
+    }
+  }
+
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
@@ -442,7 +546,11 @@ Object.assign(Language.prototype, {
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countCoverArts: countHelper('coverArts'),
+  countDays: countHelper('days'),
+  countMonths: countHelper('months'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
+  countWeeks: countHelper('weeks'),
+  countYears: countHelper('years'),
 });
diff --git a/src/static/client3.js b/src/static/client3.js
index 1e64ebe1..ce057712 100644
--- a/src/static/client3.js
+++ b/src/static/client3.js
@@ -1981,8 +1981,8 @@ for (const info of groupContributionsTableInfo) {
 // Artist link icon tooltips ------------------------------
 
 const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = {
-  hoverableLinks: null,
-  iconContainers: null,
+  hoverables: null,
+  tooltips: null,
 };
 
 function getExternalIconTooltipReferences() {
@@ -1991,21 +1991,19 @@ function getExternalIconTooltipReferences() {
   const spans =
     Array.from(document.querySelectorAll('span.contribution.has-tooltip'));
 
-  info.hoverableLinks =
-    spans
-      .map(span => span.querySelector('a'));
+  info.hoverables =
+    spans.map(span => span.querySelector('a'));
 
-  info.iconContainers =
-    spans
-      .map(span => span.querySelector('span.icons-tooltip'));
+  info.tooltips =
+    spans.map(span => span.querySelector('span.icons-tooltip'));
 }
 
 function addExternalIconTooltipPageListeners() {
   const info = externalIconTooltipInfo;
 
   for (const {hoverable, tooltip} of stitchArrays({
-    hoverable: info.hoverableLinks,
-    tooltip: info.iconContainers,
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
   })) {
     registerTooltipElement(tooltip);
     registerTooltipHoverableElement(hoverable, tooltip);
@@ -2015,6 +2013,41 @@ function addExternalIconTooltipPageListeners() {
 clientSteps.getPageReferences.push(getExternalIconTooltipReferences);
 clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners);
 
+// Datetimestamp tooltips ---------------------------------
+
+const datetimestampTooltipInfo = clientInfo.datetimestampTooltipInfo = {
+  hoverables: null,
+  tooltips: null,
+};
+
+function getDatestampTooltipReferences() {
+  const info = datetimestampTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('time'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
+}
+
+function addDatestampTooltipPageListeners() {
+  const info = datetimestampTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getDatestampTooltipReferences);
+clientSteps.addPageListeners.push(addDatestampTooltipPageListeners);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = {
diff --git a/src/static/site6.css b/src/static/site6.css
index 892458d6..b2130222 100644
--- a/src/static/site6.css
+++ b/src/static/site6.css
@@ -473,37 +473,55 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
-.contribution {
+.contribution.has-tooltip,
+.datetimestamp.has-tooltip {
   position: relative;
 }
 
-.contribution.has-tooltip > a {
+.contribution.has-tooltip > a,
+.datetimestamp.has-tooltip > time {
   text-decoration: underline;
   text-decoration-style: dotted;
 }
 
-.contribution.has-tooltip > a:hover,
-.contribution.has-tooltip > a.has-visible-tooltip {
-  text-decoration-style: wavy !important;
+.datetimestamp.has-tooltip > time {
+  cursor: default;
 }
 
-.icons {
-  font-style: normal;
-  white-space: nowrap;
+.contribution.has-tooltip > a:hover,
+.contribution.has-tooltip > a.has-visible-tooltip,
+.datetimestamp.has-tooltip > time:hover,
+.datetimestamp.has-tooltip > time.has-visible-tooltip {
+  text-decoration-style: wavy !important;
 }
 
-.icons-tooltip {
+.icons-tooltip,
+.datetimestamp-tooltip {
   position: absolute;
   z-index: 3;
   left: -34px;
   top: calc(1em + 1px);
-  padding: 3px 6px 6px 6px;
   display: none;
 }
 
-.icons-tooltip-content {
+.icons-tooltip {
+  padding: 3px 6px 6px 6px;
+  left: -34px;
+}
+
+.datetimestamp-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -10px;
+}
+
+li:not(:first-child:last-child) .datetimestamp-tooltip {
+  left: 14px;
+}
+
+.icons-tooltip-content,
+.datetimestamp-tooltip-content {
   display: block;
-  padding: 6px 2px 2px 2px;
+
   background: var(--bg-black-color);
   border: 1px dotted var(--primary-color);
   border-radius: 6px;
@@ -514,16 +532,31 @@ a:not([href]):hover {
           backdrop-filter:
     brightness(1.5) saturate(1.4) blur(4px);
 
-  -webkit-user-select: none;
-          user-select: none;
-
   box-shadow:
     0 3px 4px 4px #000000aa,
     0 -2px 4px -2px var(--primary-color) inset;
+}
+
+.icons-tooltip-content {
+  padding: 6px 2px 2px 2px;
+
+  -webkit-user-select: none;
+          user-select: none;
 
   cursor: default;
 }
 
+.datetimestamp-tooltip-content {
+  padding: 5px 6px;
+  white-space: nowrap;
+  font-size: 0.9em;
+}
+
+.icons {
+  font-style: normal;
+  white-space: nowrap;
+}
+
 .icons a:hover {
   filter: brightness(1.4);
 }
@@ -574,6 +607,10 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+.other-group-accent a {
+  color: var(--page-primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 53763dcc..c15a8f5d 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -129,6 +129,16 @@ count:
       many: ""
       other: "{DAYS} days"
 
+  months:
+    _: "{MONTHS}"
+    withUnit:
+      zero: ""
+      one: "{MONTHS} month"
+      two: ""
+      few: ""
+      many: ""
+      other: "{MONTHS} months"
+
   timesReferenced:
     _: "{TIMES_REFERENCED}"
     withUnit:
@@ -149,6 +159,16 @@ count:
       many: ""
       other: "used {TIMES_USED} times"
 
+  weeks:
+    _: "{WEEKS}"
+    withUnit:
+      zero: ""
+      one: "{WEEKS} week"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WEEKS} weeks"
+
   words:
     _: "{WORDS}"
     thousand: "{WORDS}k"
@@ -160,6 +180,16 @@ count:
       many: ""
       other: "{WORDS} words"
 
+  years:
+    _: "{YEARS}"
+    withUnit:
+      zero: ""
+      one: "{YEARS} year"
+      two: ""
+      few: ""
+      many: ""
+      other: "{YEARS} years"
+
   # Numerical things that aren't exactly counting, per se
 
   duration:
@@ -172,6 +202,24 @@ count:
       _:        "{MINUTES}:{SECONDS}"
       withUnit: "{MINUTES}:{SECONDS} minutes"
 
+  dateDuration:
+    earlier: "{DURATION} earlier"
+    later: "{DURATION} later"
+    same: "on the same date"
+    zero: "at most one day"
+    approximate: "about {DURATION}"
+    approximateEarlier: "about {DURATION} earlier"
+    approximateLater: "about {DURATION} later"
+    relativeAbsolute: "{ABSOLUTE}; {RELATIVE}"
+
+    years: "{YEARS}"
+    months: "{MONTHS}"
+    days: "{DAYS}"
+    yearsMonthsDays: "{YEARS}, {MONTHS}, {DAYS}"
+    yearsMonths: "{YEARS}, {MONTHS}"
+    yearsDays: "{YEARS}, {DAYS}"
+    monthsDays: "{MONTHS}, {DAYS}"
+
   fileSize:
     terabytes: "{TERABYTES} TB"
     gigabytes: "{GIGABYTES} GB"
@@ -220,7 +268,10 @@ releaseInfo:
 
   alsoReleasedAs:
     _: "Also released as:"
-    item: "{TRACK} (on {ALBUM})"
+
+    item:
+      _: "{TRACK} ({ALBUM})"
+      withYear: "({YEAR}) {TRACK} ({ALBUM})"
 
   tracksReferenced: "Tracks that {TRACK} references:"
   tracksThatReference: "Tracks that reference {TRACK}:"
@@ -1027,10 +1078,20 @@ groupInfoPage:
     title: "Albums"
 
     item:
-      _: "({YEAR}) {ALBUM}"
-      withoutYear: "{ALBUM}"
-      withAccent: "{ITEM} {ACCENT}"
-      otherGroupAccent: "(from {GROUP})"
+      _: >-
+        {ALBUM}
+
+      withYear: >-
+        {YEAR_ACCENT} {ALBUM}
+
+      withOtherGroup: >-
+        {ALBUM} {OTHER_GROUP_ACCENT}
+
+      withYear.withOtherGroup: >-
+        {YEAR_ACCENT} {ALBUM} {OTHER_GROUP_ACCENT}
+
+      yearAccent: "({YEAR})"
+      otherGroupAccent:  "(from {GROUP})"
 
 #
 # groupGalleryPage: