@audiofool/moondog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,3575 @@
|
|
|
1
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
2
|
+
const BIDI_CONTROLS = /[\u202a-\u202e\u2066-\u2069]/gu;
|
|
3
|
+
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6
|
+
const prototype = Object.getPrototypeOf(value);
|
|
7
|
+
return prototype === Object.prototype || prototype === null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function cleanText(value, maximum = 240) {
|
|
11
|
+
if (typeof value !== "string") return "";
|
|
12
|
+
return Array.from(
|
|
13
|
+
value
|
|
14
|
+
.replaceAll(CONTROL_CHARACTERS, " ")
|
|
15
|
+
.replaceAll(BIDI_CONTROLS, " ")
|
|
16
|
+
.replaceAll(/\s+/gu, " ")
|
|
17
|
+
.trim(),
|
|
18
|
+
)
|
|
19
|
+
.slice(0, maximum)
|
|
20
|
+
.join("");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function optionalNumber(value, { integer = false, minimum = 0 } = {}) {
|
|
24
|
+
if (!Number.isFinite(value) || value < minimum) return null;
|
|
25
|
+
if (integer && !Number.isInteger(value)) return null;
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function list(value, maximum, projector) {
|
|
30
|
+
if (!Array.isArray(value)) return [];
|
|
31
|
+
return value.slice(0, maximum).map(projector).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function compactObject(value) {
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.entries(value).filter(([, entry]) => {
|
|
37
|
+
if (entry === null || entry === undefined || entry === "") return false;
|
|
38
|
+
if (Array.isArray(entry) && entry.length === 0) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function artistSignal(item) {
|
|
45
|
+
if (!isPlainObject(item)) return null;
|
|
46
|
+
const name = cleanText(item.name);
|
|
47
|
+
if (!name) return null;
|
|
48
|
+
return compactObject({
|
|
49
|
+
name,
|
|
50
|
+
play_count: optionalNumber(item.play_count, { integer: true }),
|
|
51
|
+
listening_minutes: optionalNumber(item.listening_minutes),
|
|
52
|
+
distinct_tracks: optionalNumber(item.distinct_tracks, { integer: true }),
|
|
53
|
+
last_played_at: cleanText(item.last_played_at, 40),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function historyArcSignal(item) {
|
|
58
|
+
if (!isPlainObject(item)) return null;
|
|
59
|
+
const year = optionalNumber(item.year, { integer: true, minimum: 1900 });
|
|
60
|
+
if (year === null || year > 9999) return null;
|
|
61
|
+
const topArtist = artistSignal(item.top_artist);
|
|
62
|
+
return compactObject({
|
|
63
|
+
year,
|
|
64
|
+
event_count: optionalNumber(item.event_count, { integer: true }),
|
|
65
|
+
listening_minutes: optionalNumber(item.listening_minutes),
|
|
66
|
+
distinct_tracks: optionalNumber(item.distinct_tracks, { integer: true }),
|
|
67
|
+
first_observed_tracks: optionalNumber(item.first_observed_tracks, {
|
|
68
|
+
integer: true,
|
|
69
|
+
}),
|
|
70
|
+
top_artist: topArtist,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tasteprintMonthIndex(monthKey) {
|
|
75
|
+
if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(monthKey)) return null;
|
|
76
|
+
const year = Number(monthKey.slice(0, 4));
|
|
77
|
+
const month = Number(monthKey.slice(5, 7));
|
|
78
|
+
if (!Number.isInteger(year) || year < 1900 || year > 9999) return null;
|
|
79
|
+
return year * 12 + month - 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function monthlyActivityCellSignal(item) {
|
|
83
|
+
if (!isPlainObject(item)) return null;
|
|
84
|
+
const month = cleanText(item.month, 7);
|
|
85
|
+
if (tasteprintMonthIndex(month) === null) return null;
|
|
86
|
+
const eventCount = optionalNumber(item.event_count, { integer: true });
|
|
87
|
+
const engagedPlayCount = optionalNumber(item.engaged_play_count, {
|
|
88
|
+
integer: true,
|
|
89
|
+
});
|
|
90
|
+
const listeningMinutes = optionalNumber(item.listening_minutes);
|
|
91
|
+
const distinctTracks = optionalNumber(item.distinct_tracks, { integer: true });
|
|
92
|
+
if (
|
|
93
|
+
eventCount === null ||
|
|
94
|
+
engagedPlayCount === null ||
|
|
95
|
+
engagedPlayCount > eventCount ||
|
|
96
|
+
listeningMinutes === null ||
|
|
97
|
+
distinctTracks === null
|
|
98
|
+
) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
month,
|
|
103
|
+
event_count: eventCount,
|
|
104
|
+
engaged_play_count: engagedPlayCount,
|
|
105
|
+
listening_minutes: listeningMinutes,
|
|
106
|
+
distinct_tracks: distinctTracks,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function monthlyActivitySignal(item) {
|
|
111
|
+
if (!isPlainObject(item) || item.timezone !== "UTC") return null;
|
|
112
|
+
const firstMonth = cleanText(item.first_month, 7);
|
|
113
|
+
const lastMonth = cleanText(item.last_month, 7);
|
|
114
|
+
const firstIndex = tasteprintMonthIndex(firstMonth);
|
|
115
|
+
const lastIndex = tasteprintMonthIndex(lastMonth);
|
|
116
|
+
const retainedSpanMonths = optionalNumber(item.retained_span_months, {
|
|
117
|
+
integer: true,
|
|
118
|
+
minimum: 1,
|
|
119
|
+
});
|
|
120
|
+
const representedMonthCount = optionalNumber(item.represented_month_count, {
|
|
121
|
+
integer: true,
|
|
122
|
+
minimum: 1,
|
|
123
|
+
});
|
|
124
|
+
const activeMonthCount = optionalNumber(item.active_month_count, {
|
|
125
|
+
integer: true,
|
|
126
|
+
minimum: 1,
|
|
127
|
+
});
|
|
128
|
+
const omittedEarlierMonthCount = optionalNumber(
|
|
129
|
+
item.omitted_earlier_month_count,
|
|
130
|
+
{ integer: true },
|
|
131
|
+
);
|
|
132
|
+
const peakListeningMinutes = optionalNumber(item.peak_listening_minutes);
|
|
133
|
+
const months = list(item.months, 240, monthlyActivityCellSignal).sort(
|
|
134
|
+
(left, right) =>
|
|
135
|
+
left.month < right.month ? -1 : left.month > right.month ? 1 : 0,
|
|
136
|
+
);
|
|
137
|
+
const monthIndexes = months.map((month) => tasteprintMonthIndex(month.month));
|
|
138
|
+
if (
|
|
139
|
+
firstIndex === null ||
|
|
140
|
+
lastIndex === null ||
|
|
141
|
+
firstIndex > lastIndex ||
|
|
142
|
+
retainedSpanMonths === null ||
|
|
143
|
+
representedMonthCount === null ||
|
|
144
|
+
activeMonthCount === null ||
|
|
145
|
+
omittedEarlierMonthCount === null ||
|
|
146
|
+
peakListeningMinutes === null ||
|
|
147
|
+
months.length !== representedMonthCount ||
|
|
148
|
+
months[0]?.month !== firstMonth ||
|
|
149
|
+
months.at(-1)?.month !== lastMonth ||
|
|
150
|
+
retainedSpanMonths !== representedMonthCount + omittedEarlierMonthCount ||
|
|
151
|
+
activeMonthCount !== months.filter((month) => month.event_count > 0).length ||
|
|
152
|
+
peakListeningMinutes !== Math.max(...months.map((month) => month.listening_minutes)) ||
|
|
153
|
+
monthIndexes.some(
|
|
154
|
+
(index, position) =>
|
|
155
|
+
index === null ||
|
|
156
|
+
(position > 0 && index !== monthIndexes[position - 1] + 1),
|
|
157
|
+
)
|
|
158
|
+
) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
timezone: "UTC",
|
|
163
|
+
first_month: firstMonth,
|
|
164
|
+
last_month: lastMonth,
|
|
165
|
+
retained_span_months: retainedSpanMonths,
|
|
166
|
+
represented_month_count: representedMonthCount,
|
|
167
|
+
active_month_count: activeMonthCount,
|
|
168
|
+
omitted_earlier_month_count: omittedEarlierMonthCount,
|
|
169
|
+
peak_listening_minutes: peakListeningMinutes,
|
|
170
|
+
months,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function tasteprintSeasonIndex(value) {
|
|
175
|
+
if (!/^\d{4}-Q[1-4]$/u.test(value)) return null;
|
|
176
|
+
const year = Number(value.slice(0, 4));
|
|
177
|
+
const quarter = Number(value.slice(6));
|
|
178
|
+
if (!Number.isInteger(year) || year < 1900 || year > 9999) return null;
|
|
179
|
+
return year * 4 + quarter - 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listeningSeasonArtistSignal(item) {
|
|
183
|
+
if (!isPlainObject(item)) return null;
|
|
184
|
+
const name = cleanText(item.name);
|
|
185
|
+
const eventCount = optionalNumber(item.event_count, {
|
|
186
|
+
integer: true,
|
|
187
|
+
minimum: 1,
|
|
188
|
+
});
|
|
189
|
+
const engagedPlayCount = optionalNumber(item.engaged_play_count, {
|
|
190
|
+
integer: true,
|
|
191
|
+
});
|
|
192
|
+
const listeningMinutes = optionalNumber(item.listening_minutes);
|
|
193
|
+
const distinctTracks = optionalNumber(item.distinct_tracks, {
|
|
194
|
+
integer: true,
|
|
195
|
+
minimum: 1,
|
|
196
|
+
});
|
|
197
|
+
if (
|
|
198
|
+
!name ||
|
|
199
|
+
eventCount === null ||
|
|
200
|
+
engagedPlayCount === null ||
|
|
201
|
+
engagedPlayCount > eventCount ||
|
|
202
|
+
listeningMinutes === null ||
|
|
203
|
+
distinctTracks === null
|
|
204
|
+
) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
name,
|
|
209
|
+
event_count: eventCount,
|
|
210
|
+
engaged_play_count: engagedPlayCount,
|
|
211
|
+
listening_minutes: listeningMinutes,
|
|
212
|
+
distinct_tracks: distinctTracks,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function listeningSeasonTrackSignal(item) {
|
|
217
|
+
if (!isPlainObject(item)) return null;
|
|
218
|
+
const label = cleanText(item.label);
|
|
219
|
+
const artistCredit = cleanText(item.artist_credit);
|
|
220
|
+
const playCount = optionalNumber(item.play_count, {
|
|
221
|
+
integer: true,
|
|
222
|
+
minimum: 1,
|
|
223
|
+
});
|
|
224
|
+
const engagedPlayCount = optionalNumber(item.engaged_play_count, {
|
|
225
|
+
integer: true,
|
|
226
|
+
});
|
|
227
|
+
const listeningMinutes = optionalNumber(item.listening_minutes);
|
|
228
|
+
const explicitSkips = optionalNumber(item.explicit_skips, {
|
|
229
|
+
integer: true,
|
|
230
|
+
});
|
|
231
|
+
if (
|
|
232
|
+
!label ||
|
|
233
|
+
!artistCredit ||
|
|
234
|
+
playCount === null ||
|
|
235
|
+
engagedPlayCount === null ||
|
|
236
|
+
engagedPlayCount > playCount ||
|
|
237
|
+
listeningMinutes === null ||
|
|
238
|
+
explicitSkips === null ||
|
|
239
|
+
engagedPlayCount + explicitSkips !== playCount
|
|
240
|
+
) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return compactObject({
|
|
244
|
+
label,
|
|
245
|
+
artist_credit: artistCredit,
|
|
246
|
+
release: cleanText(item.release),
|
|
247
|
+
play_count: playCount,
|
|
248
|
+
engaged_play_count: engagedPlayCount,
|
|
249
|
+
listening_minutes: listeningMinutes,
|
|
250
|
+
explicit_skips: explicitSkips,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function listeningSeasonSignal(item) {
|
|
255
|
+
if (!isPlainObject(item)) return null;
|
|
256
|
+
const key = cleanText(item.key, 7);
|
|
257
|
+
const index = tasteprintSeasonIndex(key);
|
|
258
|
+
const startMonth = cleanText(item.start_month, 7);
|
|
259
|
+
const endMonth = cleanText(item.end_month, 7);
|
|
260
|
+
const startMonthIndex = tasteprintMonthIndex(startMonth);
|
|
261
|
+
const endMonthIndex = tasteprintMonthIndex(endMonth);
|
|
262
|
+
const retainedMonthCount = optionalNumber(item.retained_month_count, {
|
|
263
|
+
integer: true,
|
|
264
|
+
minimum: 1,
|
|
265
|
+
});
|
|
266
|
+
const activeMonthCount = optionalNumber(item.active_month_count, {
|
|
267
|
+
integer: true,
|
|
268
|
+
});
|
|
269
|
+
const eventCount = optionalNumber(item.event_count, { integer: true });
|
|
270
|
+
const engagedPlayCount = optionalNumber(item.engaged_play_count, {
|
|
271
|
+
integer: true,
|
|
272
|
+
});
|
|
273
|
+
const listeningMinutes = optionalNumber(item.listening_minutes);
|
|
274
|
+
const distinctTracks = optionalNumber(item.distinct_tracks, {
|
|
275
|
+
integer: true,
|
|
276
|
+
});
|
|
277
|
+
const firstObservedTracks = optionalNumber(item.first_observed_tracks, {
|
|
278
|
+
integer: true,
|
|
279
|
+
});
|
|
280
|
+
const returningTracks = optionalNumber(item.returning_tracks, {
|
|
281
|
+
integer: true,
|
|
282
|
+
});
|
|
283
|
+
const leadingArtist = listeningSeasonArtistSignal(item.leading_artist);
|
|
284
|
+
const signatureTrack = listeningSeasonTrackSignal(item.signature_track);
|
|
285
|
+
if (
|
|
286
|
+
index === null ||
|
|
287
|
+
startMonthIndex === null ||
|
|
288
|
+
endMonthIndex === null ||
|
|
289
|
+
startMonthIndex > endMonthIndex ||
|
|
290
|
+
Math.floor(startMonthIndex / 3) !== index ||
|
|
291
|
+
Math.floor(endMonthIndex / 3) !== index ||
|
|
292
|
+
retainedMonthCount === null ||
|
|
293
|
+
retainedMonthCount > 3 ||
|
|
294
|
+
retainedMonthCount !== endMonthIndex - startMonthIndex + 1 ||
|
|
295
|
+
activeMonthCount === null ||
|
|
296
|
+
activeMonthCount > retainedMonthCount ||
|
|
297
|
+
eventCount === null ||
|
|
298
|
+
engagedPlayCount === null ||
|
|
299
|
+
engagedPlayCount > eventCount ||
|
|
300
|
+
listeningMinutes === null ||
|
|
301
|
+
distinctTracks === null ||
|
|
302
|
+
distinctTracks > eventCount ||
|
|
303
|
+
firstObservedTracks === null ||
|
|
304
|
+
returningTracks === null ||
|
|
305
|
+
firstObservedTracks + returningTracks !== distinctTracks ||
|
|
306
|
+
(eventCount === 0 &&
|
|
307
|
+
(activeMonthCount !== 0 ||
|
|
308
|
+
engagedPlayCount !== 0 ||
|
|
309
|
+
listeningMinutes !== 0 ||
|
|
310
|
+
distinctTracks !== 0 ||
|
|
311
|
+
leadingArtist !== null ||
|
|
312
|
+
signatureTrack !== null)) ||
|
|
313
|
+
(eventCount > 0 &&
|
|
314
|
+
(activeMonthCount < 1 ||
|
|
315
|
+
distinctTracks < 1 ||
|
|
316
|
+
leadingArtist === null ||
|
|
317
|
+
signatureTrack === null)) ||
|
|
318
|
+
(leadingArtist &&
|
|
319
|
+
(leadingArtist.event_count > eventCount ||
|
|
320
|
+
leadingArtist.engaged_play_count > engagedPlayCount ||
|
|
321
|
+
leadingArtist.listening_minutes > listeningMinutes ||
|
|
322
|
+
leadingArtist.distinct_tracks > distinctTracks)) ||
|
|
323
|
+
(signatureTrack &&
|
|
324
|
+
(signatureTrack.play_count > eventCount ||
|
|
325
|
+
signatureTrack.engaged_play_count > engagedPlayCount ||
|
|
326
|
+
signatureTrack.listening_minutes > listeningMinutes))
|
|
327
|
+
) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return compactObject({
|
|
331
|
+
key,
|
|
332
|
+
start_month: startMonth,
|
|
333
|
+
end_month: endMonth,
|
|
334
|
+
retained_month_count: retainedMonthCount,
|
|
335
|
+
active_month_count: activeMonthCount,
|
|
336
|
+
event_count: eventCount,
|
|
337
|
+
engaged_play_count: engagedPlayCount,
|
|
338
|
+
listening_minutes: listeningMinutes,
|
|
339
|
+
distinct_tracks: distinctTracks,
|
|
340
|
+
first_observed_tracks: firstObservedTracks,
|
|
341
|
+
returning_tracks: returningTracks,
|
|
342
|
+
leading_artist: leadingArtist,
|
|
343
|
+
signature_track: signatureTrack,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function listeningSeasonsSignal(item) {
|
|
348
|
+
if (
|
|
349
|
+
!isPlainObject(item) ||
|
|
350
|
+
item.timezone !== "UTC" ||
|
|
351
|
+
item.alignment !== "calendar_quarter" ||
|
|
352
|
+
item.season_length_months !== 3
|
|
353
|
+
) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const retainedFirstSeason = cleanText(item.retained_first_season, 7);
|
|
357
|
+
const representedFirstSeason = cleanText(
|
|
358
|
+
item.represented_first_season,
|
|
359
|
+
7,
|
|
360
|
+
);
|
|
361
|
+
const lastSeason = cleanText(item.last_season, 7);
|
|
362
|
+
const retainedFirstIndex = tasteprintSeasonIndex(retainedFirstSeason);
|
|
363
|
+
const representedFirstIndex = tasteprintSeasonIndex(
|
|
364
|
+
representedFirstSeason,
|
|
365
|
+
);
|
|
366
|
+
const lastIndex = tasteprintSeasonIndex(lastSeason);
|
|
367
|
+
const retainedSeasonCount = optionalNumber(item.retained_season_count, {
|
|
368
|
+
integer: true,
|
|
369
|
+
minimum: 1,
|
|
370
|
+
});
|
|
371
|
+
const representedSeasonCount = optionalNumber(
|
|
372
|
+
item.represented_season_count,
|
|
373
|
+
{ integer: true, minimum: 1 },
|
|
374
|
+
);
|
|
375
|
+
const activeSeasonCount = optionalNumber(item.active_season_count, {
|
|
376
|
+
integer: true,
|
|
377
|
+
minimum: 1,
|
|
378
|
+
});
|
|
379
|
+
const representedActiveSeasonCount = optionalNumber(
|
|
380
|
+
item.represented_active_season_count,
|
|
381
|
+
{ integer: true, minimum: 1 },
|
|
382
|
+
);
|
|
383
|
+
const omittedEarlierSeasonCount = optionalNumber(
|
|
384
|
+
item.omitted_earlier_season_count,
|
|
385
|
+
{ integer: true },
|
|
386
|
+
);
|
|
387
|
+
const omittedEarlierActiveSeasonCount = optionalNumber(
|
|
388
|
+
item.omitted_earlier_active_season_count,
|
|
389
|
+
{ integer: true },
|
|
390
|
+
);
|
|
391
|
+
const rawSeasons = Array.isArray(item.seasons) ? item.seasons : [];
|
|
392
|
+
const seasons = rawSeasons.map(listeningSeasonSignal).filter(Boolean);
|
|
393
|
+
const indexes = seasons.map((season) => tasteprintSeasonIndex(season.key));
|
|
394
|
+
if (
|
|
395
|
+
retainedFirstIndex === null ||
|
|
396
|
+
representedFirstIndex === null ||
|
|
397
|
+
lastIndex === null ||
|
|
398
|
+
retainedFirstIndex > representedFirstIndex ||
|
|
399
|
+
representedFirstIndex > lastIndex ||
|
|
400
|
+
retainedSeasonCount === null ||
|
|
401
|
+
representedSeasonCount === null ||
|
|
402
|
+
representedSeasonCount > 80 ||
|
|
403
|
+
activeSeasonCount === null ||
|
|
404
|
+
representedActiveSeasonCount === null ||
|
|
405
|
+
omittedEarlierSeasonCount === null ||
|
|
406
|
+
omittedEarlierActiveSeasonCount === null ||
|
|
407
|
+
rawSeasons.length !== seasons.length ||
|
|
408
|
+
seasons.length !== representedSeasonCount ||
|
|
409
|
+
indexes[0] !== representedFirstIndex ||
|
|
410
|
+
indexes.at(-1) !== lastIndex ||
|
|
411
|
+
indexes.some(
|
|
412
|
+
(index, position) =>
|
|
413
|
+
index === null ||
|
|
414
|
+
(position > 0 && index !== indexes[position - 1] + 1),
|
|
415
|
+
) ||
|
|
416
|
+
retainedSeasonCount !== lastIndex - retainedFirstIndex + 1 ||
|
|
417
|
+
representedSeasonCount !== lastIndex - representedFirstIndex + 1 ||
|
|
418
|
+
omittedEarlierSeasonCount !== representedFirstIndex - retainedFirstIndex ||
|
|
419
|
+
activeSeasonCount !==
|
|
420
|
+
representedActiveSeasonCount + omittedEarlierActiveSeasonCount ||
|
|
421
|
+
representedActiveSeasonCount !==
|
|
422
|
+
seasons.filter((season) => season.event_count > 0).length ||
|
|
423
|
+
activeSeasonCount > retainedSeasonCount ||
|
|
424
|
+
representedActiveSeasonCount > representedSeasonCount ||
|
|
425
|
+
omittedEarlierActiveSeasonCount > omittedEarlierSeasonCount
|
|
426
|
+
) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const previewSeasons = seasons.slice(-12);
|
|
430
|
+
const previewActiveSeasonCount = previewSeasons.filter(
|
|
431
|
+
(season) => season.event_count > 0,
|
|
432
|
+
).length;
|
|
433
|
+
return {
|
|
434
|
+
timezone: "UTC",
|
|
435
|
+
alignment: "calendar_quarter",
|
|
436
|
+
season_length_months: 3,
|
|
437
|
+
retained_first_season: retainedFirstSeason,
|
|
438
|
+
represented_first_season: representedFirstSeason,
|
|
439
|
+
last_season: lastSeason,
|
|
440
|
+
retained_season_count: retainedSeasonCount,
|
|
441
|
+
represented_season_count: representedSeasonCount,
|
|
442
|
+
active_season_count: activeSeasonCount,
|
|
443
|
+
represented_active_season_count: representedActiveSeasonCount,
|
|
444
|
+
omitted_earlier_season_count: omittedEarlierSeasonCount,
|
|
445
|
+
omitted_earlier_active_season_count: omittedEarlierActiveSeasonCount,
|
|
446
|
+
preview_season_count: previewSeasons.length,
|
|
447
|
+
preview_active_season_count: previewActiveSeasonCount,
|
|
448
|
+
preview_omitted_season_count: seasons.length - previewSeasons.length,
|
|
449
|
+
preview_omitted_active_season_count:
|
|
450
|
+
representedActiveSeasonCount - previewActiveSeasonCount,
|
|
451
|
+
seasons: previewSeasons,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function artistRelationshipSignal(item) {
|
|
456
|
+
if (!isPlainObject(item)) return null;
|
|
457
|
+
const name = cleanText(item.name);
|
|
458
|
+
const firstYear = optionalNumber(item.first_year, {
|
|
459
|
+
integer: true,
|
|
460
|
+
minimum: 1900,
|
|
461
|
+
});
|
|
462
|
+
const lastYear = optionalNumber(item.last_year, {
|
|
463
|
+
integer: true,
|
|
464
|
+
minimum: 1900,
|
|
465
|
+
});
|
|
466
|
+
if (
|
|
467
|
+
!name ||
|
|
468
|
+
firstYear === null ||
|
|
469
|
+
lastYear === null ||
|
|
470
|
+
firstYear > lastYear ||
|
|
471
|
+
lastYear > 9999
|
|
472
|
+
) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
return compactObject({
|
|
476
|
+
name,
|
|
477
|
+
first_year: firstYear,
|
|
478
|
+
last_year: lastYear,
|
|
479
|
+
active_years: optionalNumber(item.active_years, {
|
|
480
|
+
integer: true,
|
|
481
|
+
minimum: 2,
|
|
482
|
+
}),
|
|
483
|
+
span_years: optionalNumber(item.span_years, {
|
|
484
|
+
integer: true,
|
|
485
|
+
minimum: 2,
|
|
486
|
+
}),
|
|
487
|
+
play_count: optionalNumber(item.play_count, { integer: true }),
|
|
488
|
+
listening_minutes: optionalNumber(item.listening_minutes),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function transitionArtistNames(value) {
|
|
493
|
+
return list(value, 4, (name) => cleanText(name)).filter(Boolean);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function yearTransitionSignal(item) {
|
|
497
|
+
if (!isPlainObject(item)) return null;
|
|
498
|
+
const fromYear = optionalNumber(item.from_year, {
|
|
499
|
+
integer: true,
|
|
500
|
+
minimum: 1900,
|
|
501
|
+
});
|
|
502
|
+
const toYear = optionalNumber(item.to_year, {
|
|
503
|
+
integer: true,
|
|
504
|
+
minimum: 1900,
|
|
505
|
+
});
|
|
506
|
+
const continuityPercent = optionalNumber(item.continuity_percent);
|
|
507
|
+
if (
|
|
508
|
+
fromYear === null ||
|
|
509
|
+
toYear === null ||
|
|
510
|
+
fromYear >= toYear ||
|
|
511
|
+
toYear > 9999 ||
|
|
512
|
+
continuityPercent === null ||
|
|
513
|
+
continuityPercent > 100
|
|
514
|
+
) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return compactObject({
|
|
518
|
+
from_year: fromYear,
|
|
519
|
+
to_year: toYear,
|
|
520
|
+
artist_limit: optionalNumber(item.artist_limit, {
|
|
521
|
+
integer: true,
|
|
522
|
+
minimum: 1,
|
|
523
|
+
}),
|
|
524
|
+
from_artist_count: optionalNumber(item.from_artist_count, {
|
|
525
|
+
integer: true,
|
|
526
|
+
}),
|
|
527
|
+
to_artist_count: optionalNumber(item.to_artist_count, {
|
|
528
|
+
integer: true,
|
|
529
|
+
}),
|
|
530
|
+
retained_artist_count: optionalNumber(item.retained_artist_count, {
|
|
531
|
+
integer: true,
|
|
532
|
+
}),
|
|
533
|
+
new_artist_count: optionalNumber(item.new_artist_count, {
|
|
534
|
+
integer: true,
|
|
535
|
+
}),
|
|
536
|
+
continuity_percent: continuityPercent,
|
|
537
|
+
retained_artists: transitionArtistNames(item.retained_artists),
|
|
538
|
+
new_artists: transitionArtistNames(item.new_artists),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function releaseDepthSignal(item) {
|
|
543
|
+
if (!isPlainObject(item)) return null;
|
|
544
|
+
const title = cleanText(item.title);
|
|
545
|
+
const artistCredit = cleanText(item.artist_credit);
|
|
546
|
+
const firstYear = optionalNumber(item.first_year, {
|
|
547
|
+
integer: true,
|
|
548
|
+
minimum: 1900,
|
|
549
|
+
});
|
|
550
|
+
const lastYear = optionalNumber(item.last_year, {
|
|
551
|
+
integer: true,
|
|
552
|
+
minimum: 1900,
|
|
553
|
+
});
|
|
554
|
+
if (
|
|
555
|
+
!title ||
|
|
556
|
+
!artistCredit ||
|
|
557
|
+
(firstYear !== null && firstYear > 9999) ||
|
|
558
|
+
(lastYear !== null && lastYear > 9999) ||
|
|
559
|
+
(firstYear !== null && lastYear !== null && firstYear > lastYear)
|
|
560
|
+
) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
return compactObject({
|
|
564
|
+
title,
|
|
565
|
+
artist_credit: artistCredit,
|
|
566
|
+
distinct_tracks: optionalNumber(item.distinct_tracks, {
|
|
567
|
+
integer: true,
|
|
568
|
+
minimum: 1,
|
|
569
|
+
}),
|
|
570
|
+
play_count: optionalNumber(item.play_count, { integer: true }),
|
|
571
|
+
engaged_play_count: optionalNumber(item.engaged_play_count, {
|
|
572
|
+
integer: true,
|
|
573
|
+
}),
|
|
574
|
+
listening_minutes: optionalNumber(item.listening_minutes),
|
|
575
|
+
first_year: firstYear,
|
|
576
|
+
last_year: lastYear,
|
|
577
|
+
active_years: optionalNumber(item.active_years, {
|
|
578
|
+
integer: true,
|
|
579
|
+
minimum: 1,
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function sessionSummarySignal(item) {
|
|
585
|
+
if (!isPlainObject(item)) return null;
|
|
586
|
+
const source = cleanText(item.source, 60);
|
|
587
|
+
const method = cleanText(item.method, 60);
|
|
588
|
+
const sessionCount = optionalNumber(item.session_count, {
|
|
589
|
+
integer: true,
|
|
590
|
+
minimum: 1,
|
|
591
|
+
});
|
|
592
|
+
const gapMinutes = optionalNumber(item.gap_minutes, {
|
|
593
|
+
integer: true,
|
|
594
|
+
minimum: 1,
|
|
595
|
+
});
|
|
596
|
+
const extendedPercent = optionalNumber(item.extended_sequence_percent);
|
|
597
|
+
if (
|
|
598
|
+
source !== "spotify_extended_history" ||
|
|
599
|
+
method !== "track_stop_gap" ||
|
|
600
|
+
sessionCount === null ||
|
|
601
|
+
gapMinutes === null ||
|
|
602
|
+
extendedPercent === null ||
|
|
603
|
+
extendedPercent > 100
|
|
604
|
+
) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
return compactObject({
|
|
608
|
+
source,
|
|
609
|
+
method,
|
|
610
|
+
gap_minutes: gapMinutes,
|
|
611
|
+
event_count: optionalNumber(item.event_count, { integer: true }),
|
|
612
|
+
session_count: sessionCount,
|
|
613
|
+
median_plays: optionalNumber(item.median_plays),
|
|
614
|
+
median_listening_minutes: optionalNumber(item.median_listening_minutes),
|
|
615
|
+
single_play_sessions: optionalNumber(item.single_play_sessions, {
|
|
616
|
+
integer: true,
|
|
617
|
+
}),
|
|
618
|
+
short_sequence_sessions: optionalNumber(item.short_sequence_sessions, {
|
|
619
|
+
integer: true,
|
|
620
|
+
}),
|
|
621
|
+
extended_sequence_sessions: optionalNumber(
|
|
622
|
+
item.extended_sequence_sessions,
|
|
623
|
+
{ integer: true },
|
|
624
|
+
),
|
|
625
|
+
extended_sequence_minimum_plays: optionalNumber(
|
|
626
|
+
item.extended_sequence_minimum_plays,
|
|
627
|
+
{ integer: true, minimum: 2 },
|
|
628
|
+
),
|
|
629
|
+
extended_sequence_percent: extendedPercent,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function trackSignal(item) {
|
|
634
|
+
if (!isPlainObject(item)) return null;
|
|
635
|
+
const label = cleanText(item.label);
|
|
636
|
+
if (!label) return null;
|
|
637
|
+
return compactObject({
|
|
638
|
+
label,
|
|
639
|
+
artist_credit: cleanText(item.artist_credit),
|
|
640
|
+
release: cleanText(item.release),
|
|
641
|
+
play_count: optionalNumber(item.play_count, { integer: true }),
|
|
642
|
+
listening_minutes: optionalNumber(item.listening_minutes),
|
|
643
|
+
playlist_count: optionalNumber(item.playlist_count, { integer: true }),
|
|
644
|
+
quiet_days: optionalNumber(item.quiet_days, { integer: true }),
|
|
645
|
+
peak_year: optionalNumber(item.peak_year, {
|
|
646
|
+
integer: true,
|
|
647
|
+
minimum: 1900,
|
|
648
|
+
}),
|
|
649
|
+
peak_year_play_count: optionalNumber(item.peak_year_play_count, {
|
|
650
|
+
integer: true,
|
|
651
|
+
}),
|
|
652
|
+
peak_year_listening_minutes: optionalNumber(
|
|
653
|
+
item.peak_year_listening_minutes,
|
|
654
|
+
),
|
|
655
|
+
rediscovery_signal: cleanText(item.rediscovery_signal, 128),
|
|
656
|
+
return_count: optionalNumber(item.return_count, {
|
|
657
|
+
integer: true,
|
|
658
|
+
minimum: 1,
|
|
659
|
+
}),
|
|
660
|
+
longest_gap_days: optionalNumber(item.longest_gap_days, {
|
|
661
|
+
integer: true,
|
|
662
|
+
minimum: 1,
|
|
663
|
+
}),
|
|
664
|
+
latest_return_at: dateValue(item.latest_return_at),
|
|
665
|
+
latest_return_gap_days: optionalNumber(item.latest_return_gap_days, {
|
|
666
|
+
integer: true,
|
|
667
|
+
minimum: 1,
|
|
668
|
+
}),
|
|
669
|
+
historical_return_signal: cleanText(
|
|
670
|
+
item.historical_return_signal,
|
|
671
|
+
128,
|
|
672
|
+
),
|
|
673
|
+
capsule_year: optionalNumber(item.capsule_year, {
|
|
674
|
+
integer: true,
|
|
675
|
+
minimum: 1900,
|
|
676
|
+
}),
|
|
677
|
+
year_play_count: optionalNumber(item.year_play_count, { integer: true }),
|
|
678
|
+
year_engaged_play_count: optionalNumber(item.year_engaged_play_count, {
|
|
679
|
+
integer: true,
|
|
680
|
+
}),
|
|
681
|
+
year_listening_minutes: optionalNumber(item.year_listening_minutes),
|
|
682
|
+
year_explicit_skips: optionalNumber(item.year_explicit_skips, {
|
|
683
|
+
integer: true,
|
|
684
|
+
}),
|
|
685
|
+
lifetime_play_count: optionalNumber(item.lifetime_play_count, {
|
|
686
|
+
integer: true,
|
|
687
|
+
}),
|
|
688
|
+
lifetime_listening_minutes: optionalNumber(
|
|
689
|
+
item.lifetime_listening_minutes,
|
|
690
|
+
),
|
|
691
|
+
representative_signal: cleanText(item.representative_signal, 128),
|
|
692
|
+
burst_count: optionalNumber(item.burst_count, {
|
|
693
|
+
integer: true,
|
|
694
|
+
minimum: 1,
|
|
695
|
+
}),
|
|
696
|
+
maximum_consecutive_plays: optionalNumber(
|
|
697
|
+
item.maximum_consecutive_plays,
|
|
698
|
+
{ integer: true, minimum: 2 },
|
|
699
|
+
),
|
|
700
|
+
plays_in_bursts: optionalNumber(item.plays_in_bursts, {
|
|
701
|
+
integer: true,
|
|
702
|
+
minimum: 2,
|
|
703
|
+
}),
|
|
704
|
+
listening_minutes_in_bursts: optionalNumber(
|
|
705
|
+
item.listening_minutes_in_bursts,
|
|
706
|
+
),
|
|
707
|
+
latest_burst_at: dateValue(item.latest_burst_at),
|
|
708
|
+
sequence_signal: cleanText(item.sequence_signal, 128),
|
|
709
|
+
first_played_at: dateValue(item.first_played_at),
|
|
710
|
+
last_played_at: cleanText(item.last_played_at, 40),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function listenerAssertion(item) {
|
|
715
|
+
if (!isPlainObject(item)) return null;
|
|
716
|
+
const entityType = cleanText(item.entity_type, 40);
|
|
717
|
+
const stance = cleanText(item.stance, 20);
|
|
718
|
+
const label = cleanText(item.label);
|
|
719
|
+
if (
|
|
720
|
+
!new Set(["artist", "track"]).has(entityType) ||
|
|
721
|
+
!new Set(["like", "avoid"]).has(stance) ||
|
|
722
|
+
!label
|
|
723
|
+
) {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
return compactObject({
|
|
727
|
+
entity_type: entityType,
|
|
728
|
+
stance,
|
|
729
|
+
label,
|
|
730
|
+
artist_credit: cleanText(item.artist_credit),
|
|
731
|
+
asserted_at: dateValue(item.asserted_at),
|
|
732
|
+
note: cleanText(item.note, 500),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function providerArtist(item) {
|
|
737
|
+
const result = artistSignal(item);
|
|
738
|
+
if (!result) return null;
|
|
739
|
+
return compactObject({
|
|
740
|
+
...result,
|
|
741
|
+
best_rank: optionalNumber(item.best_rank, { integer: true, minimum: 1 }),
|
|
742
|
+
periods: list(item.periods, 6, (period) => cleanText(period, 80)).filter(Boolean),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function providerTrack(item) {
|
|
747
|
+
const result = trackSignal(item);
|
|
748
|
+
if (!result) return null;
|
|
749
|
+
return compactObject({
|
|
750
|
+
...result,
|
|
751
|
+
best_rank: optionalNumber(item.best_rank, { integer: true, minimum: 1 }),
|
|
752
|
+
periods: list(item.periods, 6, (period) => cleanText(period, 80)).filter(Boolean),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function dateValue(value) {
|
|
757
|
+
const cleaned = cleanText(value, 40);
|
|
758
|
+
return cleaned && !Number.isNaN(Date.parse(cleaned)) ? cleaned : null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function durationDays(earliest, latest) {
|
|
762
|
+
if (!earliest || !latest) return null;
|
|
763
|
+
const delta = Date.parse(latest) - Date.parse(earliest);
|
|
764
|
+
if (!Number.isFinite(delta) || delta < 0) return null;
|
|
765
|
+
return Math.max(1, Math.floor(delta / 86_400_000) + 1);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function metric(item) {
|
|
769
|
+
if (!isPlainObject(item)) return null;
|
|
770
|
+
const name = cleanText(item.name);
|
|
771
|
+
if (!name) return null;
|
|
772
|
+
return compactObject({
|
|
773
|
+
name,
|
|
774
|
+
value: optionalNumber(item.value),
|
|
775
|
+
unit: cleanText(item.unit, 60),
|
|
776
|
+
stream_count: optionalNumber(item.stream_count, { integer: true }),
|
|
777
|
+
played_seconds: optionalNumber(item.played_seconds, { integer: true }),
|
|
778
|
+
period: cleanText(item.period, 80),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function highlight(item) {
|
|
783
|
+
if (!isPlainObject(item)) return null;
|
|
784
|
+
const label = cleanText(item.label);
|
|
785
|
+
if (!label) return null;
|
|
786
|
+
return compactObject({
|
|
787
|
+
kind: cleanText(item.kind, 100),
|
|
788
|
+
label,
|
|
789
|
+
related_label: cleanText(item.related_label),
|
|
790
|
+
metric_name: cleanText(item.metric_name, 100),
|
|
791
|
+
metric_value:
|
|
792
|
+
typeof item.metric_value === "number"
|
|
793
|
+
? optionalNumber(item.metric_value)
|
|
794
|
+
: cleanText(item.metric_value, 100),
|
|
795
|
+
observed_at: dateValue(item.observed_at),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function uniqueNames(values, maximum = 16) {
|
|
800
|
+
const seen = new Set();
|
|
801
|
+
const result = [];
|
|
802
|
+
for (const item of Array.isArray(values) ? values : []) {
|
|
803
|
+
const name = cleanText(item?.name);
|
|
804
|
+
const key = name.normalize("NFKC").toLocaleLowerCase("und");
|
|
805
|
+
if (!name || seen.has(key)) continue;
|
|
806
|
+
seen.add(key);
|
|
807
|
+
result.push(name);
|
|
808
|
+
if (result.length === maximum) break;
|
|
809
|
+
}
|
|
810
|
+
return result;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function createTasteprintView(
|
|
814
|
+
profile,
|
|
815
|
+
{
|
|
816
|
+
generatedAt = new Date().toISOString(),
|
|
817
|
+
syntheticDemo = false,
|
|
818
|
+
} = {},
|
|
819
|
+
) {
|
|
820
|
+
if (!isPlainObject(profile)) {
|
|
821
|
+
throw new TypeError("A bounded profile projection is required.");
|
|
822
|
+
}
|
|
823
|
+
if (typeof generatedAt !== "string" || Number.isNaN(Date.parse(generatedAt))) {
|
|
824
|
+
throw new TypeError("Tasteprint generatedAt must be an ISO timestamp.");
|
|
825
|
+
}
|
|
826
|
+
if (typeof syntheticDemo !== "boolean") {
|
|
827
|
+
throw new TypeError("Tasteprint syntheticDemo must be a boolean.");
|
|
828
|
+
}
|
|
829
|
+
const coverage = isPlainObject(profile.coverage) ? profile.coverage : {};
|
|
830
|
+
const behavior = isPlainObject(profile.listening_behavior)
|
|
831
|
+
? profile.listening_behavior
|
|
832
|
+
: {};
|
|
833
|
+
const context = isPlainObject(behavior.context) ? behavior.context : {};
|
|
834
|
+
const curated = isPlainObject(profile.curated_preferences)
|
|
835
|
+
? profile.curated_preferences
|
|
836
|
+
: {};
|
|
837
|
+
const provider = isPlainObject(profile.provider_signals)
|
|
838
|
+
? profile.provider_signals
|
|
839
|
+
: {};
|
|
840
|
+
const listener = isPlainObject(profile.listener_assertions)
|
|
841
|
+
? profile.listener_assertions
|
|
842
|
+
: {};
|
|
843
|
+
const source = isPlainObject(profile.listening_source)
|
|
844
|
+
? profile.listening_source
|
|
845
|
+
: {};
|
|
846
|
+
const listeningRange = isPlainObject(source.listening_range)
|
|
847
|
+
? source.listening_range
|
|
848
|
+
: {};
|
|
849
|
+
const earliest = dateValue(listeningRange.earliest);
|
|
850
|
+
const latest = dateValue(listeningRange.latest);
|
|
851
|
+
const enduringArtists = list(behavior.enduring_artists, 10, artistSignal);
|
|
852
|
+
const recentArtists = list(behavior.recent_artists, 10, artistSignal);
|
|
853
|
+
const longArcRanks = new Map(
|
|
854
|
+
enduringArtists.map((artist, index) => [
|
|
855
|
+
artist.name.normalize("NFKC").toLocaleLowerCase("und"),
|
|
856
|
+
index + 1,
|
|
857
|
+
]),
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
tasteprint_version: "moondog-tasteprint/1",
|
|
862
|
+
generated_at: new Date(generatedAt).toISOString(),
|
|
863
|
+
synthetic_demo: syntheticDemo,
|
|
864
|
+
privacy: {
|
|
865
|
+
classification: syntheticDemo
|
|
866
|
+
? "public_synthetic_demo"
|
|
867
|
+
: "private_local_tasteprint",
|
|
868
|
+
contains_personal_music_context: !syntheticDemo,
|
|
869
|
+
network_requests: "none",
|
|
870
|
+
},
|
|
871
|
+
profile_version: cleanText(profile.profile_version, 80) || "unknown",
|
|
872
|
+
coverage: compactObject({
|
|
873
|
+
effective_listening_events: optionalNumber(
|
|
874
|
+
coverage.effective_listening_events,
|
|
875
|
+
{ integer: true },
|
|
876
|
+
),
|
|
877
|
+
profiled_listening_events: optionalNumber(
|
|
878
|
+
coverage.profiled_listening_events,
|
|
879
|
+
{ integer: true },
|
|
880
|
+
),
|
|
881
|
+
listening_hours: optionalNumber(coverage.listening_hours),
|
|
882
|
+
listening_tracks: optionalNumber(coverage.listening_tracks, {
|
|
883
|
+
integer: true,
|
|
884
|
+
}),
|
|
885
|
+
resolved_listening_tracks: optionalNumber(
|
|
886
|
+
coverage.resolved_listening_tracks,
|
|
887
|
+
{ integer: true },
|
|
888
|
+
),
|
|
889
|
+
cross_format_track_links: optionalNumber(
|
|
890
|
+
coverage.cross_format_track_links,
|
|
891
|
+
{ integer: true },
|
|
892
|
+
),
|
|
893
|
+
cross_format_linked_events: optionalNumber(
|
|
894
|
+
coverage.cross_format_linked_events,
|
|
895
|
+
{ integer: true },
|
|
896
|
+
),
|
|
897
|
+
cross_format_ambiguous_tracks: optionalNumber(
|
|
898
|
+
coverage.cross_format_ambiguous_tracks,
|
|
899
|
+
{ integer: true },
|
|
900
|
+
),
|
|
901
|
+
cross_format_ambiguous_events: optionalNumber(
|
|
902
|
+
coverage.cross_format_ambiguous_events,
|
|
903
|
+
{ integer: true },
|
|
904
|
+
),
|
|
905
|
+
spotify_saved_tracks: optionalNumber(coverage.spotify_saved_tracks, {
|
|
906
|
+
integer: true,
|
|
907
|
+
}),
|
|
908
|
+
spotify_saved_albums: optionalNumber(coverage.spotify_saved_albums, {
|
|
909
|
+
integer: true,
|
|
910
|
+
}),
|
|
911
|
+
spotify_followed_artists: optionalNumber(
|
|
912
|
+
coverage.spotify_followed_artists,
|
|
913
|
+
{ integer: true },
|
|
914
|
+
),
|
|
915
|
+
spotify_playlist_memberships: optionalNumber(
|
|
916
|
+
coverage.spotify_playlist_memberships,
|
|
917
|
+
{ integer: true },
|
|
918
|
+
),
|
|
919
|
+
verified_search_interactions: optionalNumber(
|
|
920
|
+
coverage.verified_search_interactions,
|
|
921
|
+
{ integer: true },
|
|
922
|
+
),
|
|
923
|
+
library_tracks_observed: optionalNumber(coverage.tracks_observed, {
|
|
924
|
+
integer: true,
|
|
925
|
+
}),
|
|
926
|
+
library_loved_or_favorited: optionalNumber(
|
|
927
|
+
coverage.loved_or_favorited,
|
|
928
|
+
{ integer: true },
|
|
929
|
+
),
|
|
930
|
+
listener_assertion_events: optionalNumber(
|
|
931
|
+
coverage.listener_assertion_events,
|
|
932
|
+
{ integer: true },
|
|
933
|
+
),
|
|
934
|
+
active_listener_assertions: optionalNumber(
|
|
935
|
+
coverage.active_listener_assertions,
|
|
936
|
+
{ integer: true },
|
|
937
|
+
),
|
|
938
|
+
listener_retractions: optionalNumber(coverage.listener_retractions, {
|
|
939
|
+
integer: true,
|
|
940
|
+
}),
|
|
941
|
+
}),
|
|
942
|
+
timeline: compactObject({
|
|
943
|
+
earliest,
|
|
944
|
+
latest,
|
|
945
|
+
days: durationDays(earliest, latest),
|
|
946
|
+
}),
|
|
947
|
+
behavior: {
|
|
948
|
+
enduring_artists: enduringArtists,
|
|
949
|
+
recent_artists: recentArtists.map((artist) => ({
|
|
950
|
+
...artist,
|
|
951
|
+
long_arc_rank:
|
|
952
|
+
longArcRanks.get(
|
|
953
|
+
artist.name.normalize("NFKC").toLocaleLowerCase("und"),
|
|
954
|
+
) ?? null,
|
|
955
|
+
})),
|
|
956
|
+
repeat_tracks: list(behavior.repeat_tracks, 10, trackSignal),
|
|
957
|
+
recent_tracks: list(behavior.recent_tracks, 10, trackSignal),
|
|
958
|
+
rediscovery_tracks: list(
|
|
959
|
+
behavior.rediscovery_tracks,
|
|
960
|
+
10,
|
|
961
|
+
trackSignal,
|
|
962
|
+
),
|
|
963
|
+
historical_return_tracks: list(
|
|
964
|
+
behavior.historical_return_tracks,
|
|
965
|
+
10,
|
|
966
|
+
trackSignal,
|
|
967
|
+
),
|
|
968
|
+
time_capsule_tracks: list(
|
|
969
|
+
behavior.time_capsule_tracks,
|
|
970
|
+
12,
|
|
971
|
+
trackSignal,
|
|
972
|
+
).sort((left, right) => left.capsule_year - right.capsule_year),
|
|
973
|
+
back_to_back_tracks: list(
|
|
974
|
+
behavior.back_to_back_tracks,
|
|
975
|
+
10,
|
|
976
|
+
trackSignal,
|
|
977
|
+
),
|
|
978
|
+
history_arc: list(behavior.history_arc, 20, historyArcSignal).sort(
|
|
979
|
+
(left, right) => left.year - right.year,
|
|
980
|
+
),
|
|
981
|
+
monthly_activity: monthlyActivitySignal(behavior.monthly_activity),
|
|
982
|
+
listening_seasons: listeningSeasonsSignal(behavior.listening_seasons),
|
|
983
|
+
artist_relationships: list(
|
|
984
|
+
behavior.artist_relationships,
|
|
985
|
+
10,
|
|
986
|
+
artistRelationshipSignal,
|
|
987
|
+
),
|
|
988
|
+
year_transitions: list(
|
|
989
|
+
behavior.year_transitions,
|
|
990
|
+
12,
|
|
991
|
+
yearTransitionSignal,
|
|
992
|
+
).sort((left, right) => left.from_year - right.from_year),
|
|
993
|
+
release_depth: list(
|
|
994
|
+
behavior.release_depth,
|
|
995
|
+
10,
|
|
996
|
+
releaseDepthSignal,
|
|
997
|
+
),
|
|
998
|
+
session_summary: sessionSummarySignal(behavior.session_summary),
|
|
999
|
+
context: compactObject({
|
|
1000
|
+
recent_window_days: optionalNumber(context.recent_window_days, {
|
|
1001
|
+
integer: true,
|
|
1002
|
+
minimum: 1,
|
|
1003
|
+
}),
|
|
1004
|
+
effective_events_profiled: optionalNumber(
|
|
1005
|
+
context.effective_events_profiled,
|
|
1006
|
+
{ integer: true },
|
|
1007
|
+
),
|
|
1008
|
+
start_reason_events: optionalNumber(context.start_reason_events, {
|
|
1009
|
+
integer: true,
|
|
1010
|
+
}),
|
|
1011
|
+
trackdone_starts: optionalNumber(context.trackdone_starts, {
|
|
1012
|
+
integer: true,
|
|
1013
|
+
}),
|
|
1014
|
+
end_reason_events: optionalNumber(context.end_reason_events, {
|
|
1015
|
+
integer: true,
|
|
1016
|
+
}),
|
|
1017
|
+
skip_state_events: optionalNumber(context.skip_state_events, {
|
|
1018
|
+
integer: true,
|
|
1019
|
+
}),
|
|
1020
|
+
explicit_skips: optionalNumber(context.explicit_skips, {
|
|
1021
|
+
integer: true,
|
|
1022
|
+
}),
|
|
1023
|
+
trackdone_endings: optionalNumber(context.trackdone_endings, {
|
|
1024
|
+
integer: true,
|
|
1025
|
+
}),
|
|
1026
|
+
direct_selection_starts: optionalNumber(
|
|
1027
|
+
context.direct_selection_starts,
|
|
1028
|
+
{ integer: true },
|
|
1029
|
+
),
|
|
1030
|
+
shuffle_events: optionalNumber(context.shuffle_events, {
|
|
1031
|
+
integer: true,
|
|
1032
|
+
}),
|
|
1033
|
+
shuffle_state_events: optionalNumber(context.shuffle_state_events, {
|
|
1034
|
+
integer: true,
|
|
1035
|
+
}),
|
|
1036
|
+
offline_events: optionalNumber(context.offline_events, {
|
|
1037
|
+
integer: true,
|
|
1038
|
+
}),
|
|
1039
|
+
offline_state_events: optionalNumber(context.offline_state_events, {
|
|
1040
|
+
integer: true,
|
|
1041
|
+
}),
|
|
1042
|
+
incognito_events_excluded: optionalNumber(
|
|
1043
|
+
context.incognito_events_excluded,
|
|
1044
|
+
{ integer: true },
|
|
1045
|
+
),
|
|
1046
|
+
rediscovery_quiet_days: optionalNumber(
|
|
1047
|
+
context.rediscovery_quiet_days,
|
|
1048
|
+
{ integer: true, minimum: 1 },
|
|
1049
|
+
),
|
|
1050
|
+
rediscovery_minimum_plays: optionalNumber(
|
|
1051
|
+
context.rediscovery_minimum_plays,
|
|
1052
|
+
{ integer: true, minimum: 1 },
|
|
1053
|
+
),
|
|
1054
|
+
rediscovery_minimum_engaged_plays: optionalNumber(
|
|
1055
|
+
context.rediscovery_minimum_engaged_plays,
|
|
1056
|
+
{ integer: true, minimum: 1 },
|
|
1057
|
+
),
|
|
1058
|
+
rediscovery_minimum_listening_minutes: optionalNumber(
|
|
1059
|
+
context.rediscovery_minimum_listening_minutes,
|
|
1060
|
+
{ integer: true, minimum: 1 },
|
|
1061
|
+
),
|
|
1062
|
+
historical_return_minimum_gap_days: optionalNumber(
|
|
1063
|
+
context.historical_return_minimum_gap_days,
|
|
1064
|
+
{ integer: true, minimum: 1 },
|
|
1065
|
+
),
|
|
1066
|
+
historical_return_minimum_plays: optionalNumber(
|
|
1067
|
+
context.historical_return_minimum_plays,
|
|
1068
|
+
{ integer: true, minimum: 1 },
|
|
1069
|
+
),
|
|
1070
|
+
historical_return_minimum_engaged_plays: optionalNumber(
|
|
1071
|
+
context.historical_return_minimum_engaged_plays,
|
|
1072
|
+
{ integer: true, minimum: 1 },
|
|
1073
|
+
),
|
|
1074
|
+
historical_return_minimum_listening_minutes: optionalNumber(
|
|
1075
|
+
context.historical_return_minimum_listening_minutes,
|
|
1076
|
+
{ integer: true, minimum: 1 },
|
|
1077
|
+
),
|
|
1078
|
+
time_capsule_minimum_years: optionalNumber(
|
|
1079
|
+
context.time_capsule_minimum_years,
|
|
1080
|
+
{ integer: true, minimum: 2 },
|
|
1081
|
+
),
|
|
1082
|
+
time_capsule_minimum_engaged_plays: optionalNumber(
|
|
1083
|
+
context.time_capsule_minimum_engaged_plays,
|
|
1084
|
+
{ integer: true, minimum: 1 },
|
|
1085
|
+
),
|
|
1086
|
+
time_capsule_minimum_listening_minutes: optionalNumber(
|
|
1087
|
+
context.time_capsule_minimum_listening_minutes,
|
|
1088
|
+
{ integer: true, minimum: 1 },
|
|
1089
|
+
),
|
|
1090
|
+
relationship_minimum_years: optionalNumber(
|
|
1091
|
+
context.relationship_minimum_years,
|
|
1092
|
+
{ integer: true, minimum: 2 },
|
|
1093
|
+
),
|
|
1094
|
+
continuity_artist_limit: optionalNumber(
|
|
1095
|
+
context.continuity_artist_limit,
|
|
1096
|
+
{ integer: true, minimum: 1 },
|
|
1097
|
+
),
|
|
1098
|
+
release_minimum_distinct_tracks: optionalNumber(
|
|
1099
|
+
context.release_minimum_distinct_tracks,
|
|
1100
|
+
{ integer: true, minimum: 1 },
|
|
1101
|
+
),
|
|
1102
|
+
session_gap_minutes: optionalNumber(context.session_gap_minutes, {
|
|
1103
|
+
integer: true,
|
|
1104
|
+
minimum: 1,
|
|
1105
|
+
}),
|
|
1106
|
+
extended_sequence_minimum_plays: optionalNumber(
|
|
1107
|
+
context.extended_sequence_minimum_plays,
|
|
1108
|
+
{ integer: true, minimum: 2 },
|
|
1109
|
+
),
|
|
1110
|
+
back_to_back_minimum_consecutive_plays: optionalNumber(
|
|
1111
|
+
context.back_to_back_minimum_consecutive_plays,
|
|
1112
|
+
{ integer: true, minimum: 2 },
|
|
1113
|
+
),
|
|
1114
|
+
back_to_back_minimum_played_seconds: optionalNumber(
|
|
1115
|
+
context.back_to_back_minimum_played_seconds,
|
|
1116
|
+
{ integer: true, minimum: 1 },
|
|
1117
|
+
),
|
|
1118
|
+
back_to_back_maximum_gap_minutes: optionalNumber(
|
|
1119
|
+
context.back_to_back_maximum_gap_minutes,
|
|
1120
|
+
{ integer: true, minimum: 1 },
|
|
1121
|
+
),
|
|
1122
|
+
}),
|
|
1123
|
+
},
|
|
1124
|
+
deliberate: {
|
|
1125
|
+
listener_preferences: list(
|
|
1126
|
+
listener.preferences,
|
|
1127
|
+
10,
|
|
1128
|
+
listenerAssertion,
|
|
1129
|
+
),
|
|
1130
|
+
listener_avoids: list(listener.avoids, 10, listenerAssertion),
|
|
1131
|
+
strong_preferences: list(profile.strong_preferences, 10, (item) => {
|
|
1132
|
+
if (!isPlainObject(item)) return null;
|
|
1133
|
+
const label = cleanText(item.label);
|
|
1134
|
+
return label
|
|
1135
|
+
? compactObject({ label, signal: cleanText(item.signal) })
|
|
1136
|
+
: null;
|
|
1137
|
+
}),
|
|
1138
|
+
saved_tracks: list(curated.saved_tracks, 10, trackSignal),
|
|
1139
|
+
playlist_anchors: list(curated.playlist_anchors, 10, trackSignal),
|
|
1140
|
+
followed_artists: list(curated.followed_artists, 10, artistSignal),
|
|
1141
|
+
saved_albums: list(curated.saved_albums, 10, trackSignal),
|
|
1142
|
+
},
|
|
1143
|
+
facets: {
|
|
1144
|
+
artists: uniqueNames(profile.artist_facets),
|
|
1145
|
+
genres: uniqueNames(profile.genre_facets),
|
|
1146
|
+
},
|
|
1147
|
+
search_intent: list(profile.search_intent, 10, (item) => {
|
|
1148
|
+
if (!isPlainObject(item)) return null;
|
|
1149
|
+
const query = cleanText(item.query);
|
|
1150
|
+
if (!query) return null;
|
|
1151
|
+
return compactObject({
|
|
1152
|
+
query,
|
|
1153
|
+
interactions: optionalNumber(item.interactions, { integer: true }),
|
|
1154
|
+
result_entity_types: list(item.result_entity_types, 6, (entry) =>
|
|
1155
|
+
cleanText(entry, 60),
|
|
1156
|
+
).filter(Boolean),
|
|
1157
|
+
last_searched_at: dateValue(item.last_searched_at),
|
|
1158
|
+
});
|
|
1159
|
+
}),
|
|
1160
|
+
provider_snapshot: {
|
|
1161
|
+
artists: list(provider.artists, 10, providerArtist),
|
|
1162
|
+
tracks: list(provider.tracks, 10, providerTrack),
|
|
1163
|
+
genres: list(provider.genres, 10, (item) => {
|
|
1164
|
+
if (!isPlainObject(item)) return null;
|
|
1165
|
+
const name = cleanText(item.name);
|
|
1166
|
+
return name
|
|
1167
|
+
? compactObject({
|
|
1168
|
+
name,
|
|
1169
|
+
rank: optionalNumber(item.rank, { integer: true, minimum: 1 }),
|
|
1170
|
+
period: cleanText(item.period, 80),
|
|
1171
|
+
})
|
|
1172
|
+
: null;
|
|
1173
|
+
}),
|
|
1174
|
+
highlights: list(provider.highlights, 6, highlight),
|
|
1175
|
+
metrics: list(provider.metrics, 10, metric),
|
|
1176
|
+
},
|
|
1177
|
+
limitations: list(profile.limitations, 8, (item) => cleanText(item, 500)).filter(
|
|
1178
|
+
Boolean,
|
|
1179
|
+
),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function escapeHtml(value) {
|
|
1184
|
+
return String(value)
|
|
1185
|
+
.replaceAll("&", "&")
|
|
1186
|
+
.replaceAll("<", "<")
|
|
1187
|
+
.replaceAll(">", ">")
|
|
1188
|
+
.replaceAll('"', """)
|
|
1189
|
+
.replaceAll("'", "'");
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function number(value, maximumFractionDigits = 0) {
|
|
1193
|
+
if (!Number.isFinite(value)) return "Unknown";
|
|
1194
|
+
return value.toLocaleString("en-US", { maximumFractionDigits });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function listeningTime(minutesValue) {
|
|
1198
|
+
if (!Number.isFinite(minutesValue)) return null;
|
|
1199
|
+
if (minutesValue < 120) return `${number(Math.round(minutesValue))} min`;
|
|
1200
|
+
return `${number(minutesValue / 60, 1)} h`;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function dateLabel(value) {
|
|
1204
|
+
if (!value) return "Unknown";
|
|
1205
|
+
return new Intl.DateTimeFormat("en", {
|
|
1206
|
+
dateStyle: "medium",
|
|
1207
|
+
timeZone: "UTC",
|
|
1208
|
+
}).format(new Date(value));
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function spanLabel(days) {
|
|
1212
|
+
if (!Number.isFinite(days)) return "Your listening life";
|
|
1213
|
+
if (days >= 730) return `${number(days / 365.25, 1)} years of listening`;
|
|
1214
|
+
if (days >= 60) return `${number(days / 30.44, 1)} months of listening`;
|
|
1215
|
+
return `${number(days)} days of listening`;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function percentage(value, total) {
|
|
1219
|
+
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return null;
|
|
1220
|
+
return Math.round((value / total) * 1000) / 10;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function metricCard(value, label, note = "") {
|
|
1224
|
+
return `<article class="metric-card">
|
|
1225
|
+
<strong>${escapeHtml(value)}</strong>
|
|
1226
|
+
<span>${escapeHtml(label)}</span>
|
|
1227
|
+
${note ? `<small>${escapeHtml(note)}</small>` : ""}
|
|
1228
|
+
</article>`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function artistRows(items, { recent = false } = {}) {
|
|
1232
|
+
if (items.length === 0) return '<p class="empty">Nothing here yet.</p>';
|
|
1233
|
+
const maximum = Math.max(
|
|
1234
|
+
1,
|
|
1235
|
+
...items.map((item) => item.listening_minutes ?? item.play_count ?? 0),
|
|
1236
|
+
);
|
|
1237
|
+
return `<ol class="rank-list">
|
|
1238
|
+
${items
|
|
1239
|
+
.map((item, index) => {
|
|
1240
|
+
const signal = item.listening_minutes ?? item.play_count ?? 0;
|
|
1241
|
+
const width = Math.max(4, Math.round((signal / maximum) * 1000) / 10);
|
|
1242
|
+
const details = [
|
|
1243
|
+
Number.isFinite(item.listening_minutes)
|
|
1244
|
+
? listeningTime(item.listening_minutes)
|
|
1245
|
+
: null,
|
|
1246
|
+
Number.isInteger(item.play_count) ? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}` : null,
|
|
1247
|
+
Number.isInteger(item.distinct_tracks)
|
|
1248
|
+
? `${number(item.distinct_tracks)} ${item.distinct_tracks === 1 ? "track" : "tracks"}`
|
|
1249
|
+
: null,
|
|
1250
|
+
].filter(Boolean);
|
|
1251
|
+
const orbit = recent
|
|
1252
|
+
? Number.isInteger(item.long_arc_rank)
|
|
1253
|
+
? `#${item.long_arc_rank} across the years`
|
|
1254
|
+
: "new lately"
|
|
1255
|
+
: "";
|
|
1256
|
+
return `<li>
|
|
1257
|
+
<div class="rank-number">${String(index + 1).padStart(2, "0")}</div>
|
|
1258
|
+
<div class="rank-content">
|
|
1259
|
+
<div class="rank-heading"><strong>${escapeHtml(item.name)}</strong>${orbit ? `<span>${escapeHtml(orbit)}</span>` : ""}</div>
|
|
1260
|
+
<div class="bar" aria-hidden="true"><i style="width:${width}%"></i></div>
|
|
1261
|
+
<small>${escapeHtml(details.join(" / "))}</small>
|
|
1262
|
+
</div>
|
|
1263
|
+
</li>`;
|
|
1264
|
+
})
|
|
1265
|
+
.join("")}
|
|
1266
|
+
</ol>`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function historyArcRows(items) {
|
|
1270
|
+
if (items.length === 0) {
|
|
1271
|
+
return '<p class="empty">No year-by-year history yet.</p>';
|
|
1272
|
+
}
|
|
1273
|
+
const maximum = Math.max(
|
|
1274
|
+
1,
|
|
1275
|
+
...items.map((item) => item.listening_minutes ?? item.event_count ?? 0),
|
|
1276
|
+
);
|
|
1277
|
+
return `<ol class="history-arc">
|
|
1278
|
+
${items
|
|
1279
|
+
.map((item) => {
|
|
1280
|
+
const signal = item.listening_minutes ?? item.event_count ?? 0;
|
|
1281
|
+
const width = Math.max(3, Math.round((signal / maximum) * 1000) / 10);
|
|
1282
|
+
const details = [
|
|
1283
|
+
Number.isInteger(item.event_count)
|
|
1284
|
+
? `${number(item.event_count)} ${item.event_count === 1 ? "play" : "plays"}`
|
|
1285
|
+
: null,
|
|
1286
|
+
Number.isInteger(item.distinct_tracks)
|
|
1287
|
+
? `${number(item.distinct_tracks)} tracks`
|
|
1288
|
+
: null,
|
|
1289
|
+
Number.isInteger(item.first_observed_tracks)
|
|
1290
|
+
? `${number(item.first_observed_tracks)} new`
|
|
1291
|
+
: null,
|
|
1292
|
+
].filter(Boolean);
|
|
1293
|
+
const topArtistTime = listeningTime(item.top_artist?.listening_minutes);
|
|
1294
|
+
const topArtistDetails = [
|
|
1295
|
+
topArtistTime,
|
|
1296
|
+
Number.isInteger(item.top_artist?.play_count)
|
|
1297
|
+
? `${number(item.top_artist.play_count)} ${item.top_artist.play_count === 1 ? "play" : "plays"}`
|
|
1298
|
+
: null,
|
|
1299
|
+
].filter(Boolean);
|
|
1300
|
+
return `<li>
|
|
1301
|
+
<div class="arc-year"><strong>${escapeHtml(item.year)}</strong><span>UTC</span></div>
|
|
1302
|
+
<div class="arc-body">
|
|
1303
|
+
<div class="arc-heading"><strong>${escapeHtml(listeningTime(item.listening_minutes) ?? "Unknown time")}</strong><span>listened</span></div>
|
|
1304
|
+
<div class="bar" aria-hidden="true"><i style="width:${width}%"></i></div>
|
|
1305
|
+
${details.length > 0 ? `<small>${escapeHtml(details.join(" / "))}</small>` : ""}
|
|
1306
|
+
</div>
|
|
1307
|
+
<div class="arc-artist"><span>Most heard artist</span><strong>${escapeHtml(item.top_artist?.name ?? "Unknown artist")}</strong>${topArtistDetails.length > 0 ? `<small>${escapeHtml(topArtistDetails.join(" / "))}</small>` : ""}</div>
|
|
1308
|
+
</li>`;
|
|
1309
|
+
})
|
|
1310
|
+
.join("")}
|
|
1311
|
+
</ol>`;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Play counts are the default reason, so only stronger evidence gets named.
|
|
1315
|
+
const signalPhrases = {
|
|
1316
|
+
"historical attention only": null,
|
|
1317
|
+
"adjacent retained plays": null,
|
|
1318
|
+
"saved-library state": "saved in your library",
|
|
1319
|
+
"private playlist curation": "on one of your playlists",
|
|
1320
|
+
"explicit listener preference": "you said you like it",
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
function signalPhrase(value) {
|
|
1324
|
+
if (typeof value !== "string" || !value) return null;
|
|
1325
|
+
return Object.hasOwn(signalPhrases, value) ? signalPhrases[value] : value;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function trackRows(items) {
|
|
1329
|
+
if (items.length === 0) return '<p class="empty">Nothing here yet.</p>';
|
|
1330
|
+
return `<ol class="track-list">
|
|
1331
|
+
${items
|
|
1332
|
+
.map((item, index) => {
|
|
1333
|
+
const details = [
|
|
1334
|
+
Number.isInteger(item.play_count) ? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}` : null,
|
|
1335
|
+
listeningTime(item.listening_minutes),
|
|
1336
|
+
Number.isInteger(item.playlist_count)
|
|
1337
|
+
? `on ${number(item.playlist_count)} ${item.playlist_count === 1 ? "playlist" : "playlists"}`
|
|
1338
|
+
: null,
|
|
1339
|
+
Number.isInteger(item.quiet_days)
|
|
1340
|
+
? `quiet for ${number(item.quiet_days)} days`
|
|
1341
|
+
: null,
|
|
1342
|
+
Number.isInteger(item.peak_year)
|
|
1343
|
+
? `biggest in ${item.peak_year}`
|
|
1344
|
+
: null,
|
|
1345
|
+
signalPhrase(item.rediscovery_signal),
|
|
1346
|
+
Number.isInteger(item.return_count)
|
|
1347
|
+
? `came back ${item.return_count === 1 ? "once" : `${number(item.return_count)} times`}`
|
|
1348
|
+
: null,
|
|
1349
|
+
Number.isInteger(item.longest_gap_days)
|
|
1350
|
+
? `longest time away ${number(item.longest_gap_days)} days`
|
|
1351
|
+
: null,
|
|
1352
|
+
Number.isInteger(item.latest_return_gap_days)
|
|
1353
|
+
? `last back after ${number(item.latest_return_gap_days)} days`
|
|
1354
|
+
: null,
|
|
1355
|
+
signalPhrase(item.historical_return_signal),
|
|
1356
|
+
Number.isInteger(item.capsule_year)
|
|
1357
|
+
? `your song of ${item.capsule_year}`
|
|
1358
|
+
: null,
|
|
1359
|
+
Number.isInteger(item.year_play_count)
|
|
1360
|
+
? `${number(item.year_play_count)} ${item.year_play_count === 1 ? "play" : "plays"} that year`
|
|
1361
|
+
: null,
|
|
1362
|
+
Number.isFinite(item.year_listening_minutes)
|
|
1363
|
+
? `${listeningTime(item.year_listening_minutes)} that year`
|
|
1364
|
+
: null,
|
|
1365
|
+
signalPhrase(item.representative_signal),
|
|
1366
|
+
Number.isInteger(item.maximum_consecutive_plays)
|
|
1367
|
+
? `up to ${number(item.maximum_consecutive_plays)} in a row`
|
|
1368
|
+
: null,
|
|
1369
|
+
Number.isInteger(item.burst_count)
|
|
1370
|
+
? `${number(item.burst_count)} ${item.burst_count === 1 ? "run" : "runs"}`
|
|
1371
|
+
: null,
|
|
1372
|
+
Number.isInteger(item.plays_in_bursts)
|
|
1373
|
+
? `${number(item.plays_in_bursts)} plays in those runs`
|
|
1374
|
+
: null,
|
|
1375
|
+
Number.isFinite(item.listening_minutes_in_bursts)
|
|
1376
|
+
? `${listeningTime(item.listening_minutes_in_bursts)} in those runs`
|
|
1377
|
+
: null,
|
|
1378
|
+
signalPhrase(item.sequence_signal),
|
|
1379
|
+
].filter(Boolean);
|
|
1380
|
+
return `<li>
|
|
1381
|
+
<span class="track-index">${String(index + 1).padStart(2, "0")}</span>
|
|
1382
|
+
<div>
|
|
1383
|
+
<strong>${escapeHtml(item.label)}</strong>
|
|
1384
|
+
<p>${escapeHtml([item.artist_credit, item.release].filter(Boolean).join(" / "))}</p>
|
|
1385
|
+
${details.length > 0 ? `<small>${escapeHtml(details.join(" / "))}</small>` : ""}
|
|
1386
|
+
</div>
|
|
1387
|
+
</li>`;
|
|
1388
|
+
})
|
|
1389
|
+
.join("")}
|
|
1390
|
+
</ol>`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function tagCloud(items) {
|
|
1394
|
+
if (items.length === 0) return '<p class="empty">No artists or genres yet.</p>';
|
|
1395
|
+
return `<ul class="tags">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function signalList(items) {
|
|
1399
|
+
if (items.length === 0) return '<p class="empty">No choices yet.</p>';
|
|
1400
|
+
return `<ul class="signal-list">${items
|
|
1401
|
+
.map(
|
|
1402
|
+
(item) => `<li><strong>${escapeHtml(item.label)}</strong>${
|
|
1403
|
+
item.signal ? `<span>${escapeHtml(item.signal)}</span>` : ""
|
|
1404
|
+
}</li>`,
|
|
1405
|
+
)
|
|
1406
|
+
.join("")}</ul>`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function correctionSignalList(items, stance) {
|
|
1410
|
+
if (items.length === 0) {
|
|
1411
|
+
return `<p class="empty">${stance === "avoid" ? "You have not kept anything out." : "You have not marked anything as a like."}</p>`;
|
|
1412
|
+
}
|
|
1413
|
+
return signalList(
|
|
1414
|
+
items.map((item) => {
|
|
1415
|
+
const target = [item.label, item.artist_credit].filter(Boolean).join(" / ");
|
|
1416
|
+
const details = [
|
|
1417
|
+
item.entity_type,
|
|
1418
|
+
item.asserted_at ? dateLabel(item.asserted_at) : null,
|
|
1419
|
+
item.note,
|
|
1420
|
+
].filter(Boolean);
|
|
1421
|
+
return { label: target, signal: details.join(" / ") };
|
|
1422
|
+
}),
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function nameRows(items) {
|
|
1427
|
+
if (items.length === 0) return '<p class="empty">Nothing here yet.</p>';
|
|
1428
|
+
return `<ul class="compact-list">${items
|
|
1429
|
+
.map((item) => {
|
|
1430
|
+
const detail = listeningTime(item.listening_minutes);
|
|
1431
|
+
return `<li><strong>${escapeHtml(item.name)}</strong>${detail ? `<span>${escapeHtml(detail)}</span>` : ""}</li>`;
|
|
1432
|
+
})
|
|
1433
|
+
.join("")}</ul>`;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function searchRows(items) {
|
|
1437
|
+
if (items.length === 0) return '<p class="empty">No music searches yet.</p>';
|
|
1438
|
+
return `<ul class="compact-list">${items
|
|
1439
|
+
.map((item) => {
|
|
1440
|
+
const details = [
|
|
1441
|
+
Number.isInteger(item.interactions)
|
|
1442
|
+
? `${number(item.interactions)} ${item.interactions === 1 ? "time" : "times"}`
|
|
1443
|
+
: null,
|
|
1444
|
+
Array.isArray(item.result_entity_types)
|
|
1445
|
+
? item.result_entity_types.join(", ")
|
|
1446
|
+
: null,
|
|
1447
|
+
].filter(Boolean);
|
|
1448
|
+
return `<li><strong>${escapeHtml(item.query)}</strong>${details.length > 0 ? `<span>${escapeHtml(details.join(" / "))}</span>` : ""}</li>`;
|
|
1449
|
+
})
|
|
1450
|
+
.join("")}</ul>`;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function behaviorCard(value, total, label, boundary, coverageLabel) {
|
|
1454
|
+
const share =
|
|
1455
|
+
Number.isFinite(value) && Number.isFinite(total) && value <= total
|
|
1456
|
+
? percentage(value, total)
|
|
1457
|
+
: null;
|
|
1458
|
+
return `<article class="behavior-card">
|
|
1459
|
+
<div><strong>${escapeHtml(number(value))}</strong>${share === null ? "" : `<span>${escapeHtml(number(share, 1))}%</span>`}</div>
|
|
1460
|
+
<h3>${escapeHtml(label)}</h3>
|
|
1461
|
+
<p>${escapeHtml(boundary)}</p>
|
|
1462
|
+
${Number.isFinite(total) && coverageLabel ? `<small>${escapeHtml(`${number(total)} ${coverageLabel}`)}</small>` : ""}
|
|
1463
|
+
</article>`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function providerRows(items, type) {
|
|
1467
|
+
if (items.length === 0) return '<p class="empty">Spotify did not rank anything here.</p>';
|
|
1468
|
+
return `<ol class="provider-list">${items
|
|
1469
|
+
.map((item, index) => {
|
|
1470
|
+
const label = type === "artist" ? item.name : item.label;
|
|
1471
|
+
const detail = type === "artist" ? "" : item.artist_credit;
|
|
1472
|
+
const rank = Number.isInteger(item.best_rank) ? `best at #${item.best_rank}` : "ranked";
|
|
1473
|
+
const periods = Array.isArray(item.periods) && item.periods.length > 0
|
|
1474
|
+
? item.periods.join(", ")
|
|
1475
|
+
: "from your export";
|
|
1476
|
+
return `<li><span>${String(index + 1).padStart(2, "0")}</span><div><strong>${escapeHtml(label)}</strong>${detail ? `<p>${escapeHtml(detail)}</p>` : ""}<small>${escapeHtml(`${rank} / ${periods}`)}</small></div></li>`;
|
|
1477
|
+
})
|
|
1478
|
+
.join("")}</ol>`;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function snapshotCards(view) {
|
|
1482
|
+
const highlights = view.provider_snapshot.highlights.map((item) => {
|
|
1483
|
+
const detail = [item.kind, item.related_label].filter(Boolean).join(" / ");
|
|
1484
|
+
return `<article><span>From Spotify</span><strong>${escapeHtml(item.label)}</strong>${detail ? `<p>${escapeHtml(detail)}</p>` : ""}</article>`;
|
|
1485
|
+
});
|
|
1486
|
+
const metrics = view.provider_snapshot.metrics.map((item) => {
|
|
1487
|
+
const value = Number.isFinite(item.value)
|
|
1488
|
+
? `${number(item.value, 1)}${item.unit ? ` ${item.unit}` : ""}`
|
|
1489
|
+
: Number.isInteger(item.stream_count)
|
|
1490
|
+
? `${number(item.stream_count)} streams`
|
|
1491
|
+
: Number.isInteger(item.played_seconds)
|
|
1492
|
+
? listeningTime(item.played_seconds / 60)
|
|
1493
|
+
: "Recorded metric";
|
|
1494
|
+
return `<article><span>${escapeHtml(item.period || "From Spotify")}</span><strong>${escapeHtml(value)}</strong><p>${escapeHtml(item.name)}</p></article>`;
|
|
1495
|
+
});
|
|
1496
|
+
const cards = [...highlights, ...metrics].slice(0, 8);
|
|
1497
|
+
return cards.length > 0
|
|
1498
|
+
? `<div class="snapshot-cards">${cards.join("")}</div>`
|
|
1499
|
+
: "";
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function naturalList(items) {
|
|
1503
|
+
if (items.length === 0) return "";
|
|
1504
|
+
if (items.length === 1) return items[0];
|
|
1505
|
+
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
|
1506
|
+
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function timeMachineSelectionNote(view) {
|
|
1510
|
+
const retainedYears = [
|
|
1511
|
+
...new Set(
|
|
1512
|
+
view.behavior.history_arc
|
|
1513
|
+
.map((item) => item.year)
|
|
1514
|
+
.filter(Number.isInteger),
|
|
1515
|
+
),
|
|
1516
|
+
].sort((left, right) => left - right);
|
|
1517
|
+
const selectedYears = new Set(
|
|
1518
|
+
view.behavior.time_capsule_tracks.map((item) => item.capsule_year),
|
|
1519
|
+
);
|
|
1520
|
+
if (retainedYears.length === 0 || selectedYears.size === 0) return "";
|
|
1521
|
+
|
|
1522
|
+
const representedYears = retainedYears.filter((year) =>
|
|
1523
|
+
selectedYears.has(year),
|
|
1524
|
+
);
|
|
1525
|
+
const unrepresentedYears = retainedYears.filter(
|
|
1526
|
+
(year) => !selectedYears.has(year),
|
|
1527
|
+
);
|
|
1528
|
+
const retainedNoun = retainedYears.length === 1 ? "year" : "years";
|
|
1529
|
+
const landmarkPhrase = representedYears.length === 1
|
|
1530
|
+
? "has a song"
|
|
1531
|
+
: "have a song";
|
|
1532
|
+
const coverageCopy = `${number(representedYears.length)} of ${number(retainedYears.length)} ${retainedNoun} ${landmarkPhrase}.`;
|
|
1533
|
+
const unrepresentedCopy = unrepresentedYears.length === 0
|
|
1534
|
+
? "Every year has one."
|
|
1535
|
+
: unrepresentedYears.length === 1
|
|
1536
|
+
? `${unrepresentedYears[0]} is still in Year by year, but no song stood out enough to pick.`
|
|
1537
|
+
: `${naturalList(unrepresentedYears.map(String))} are still in Year by year, but no song stood out enough to pick.`;
|
|
1538
|
+
const minimumEngagedPlays =
|
|
1539
|
+
view.behavior.context.time_capsule_minimum_engaged_plays;
|
|
1540
|
+
const minimumListeningMinutes =
|
|
1541
|
+
view.behavior.context.time_capsule_minimum_listening_minutes;
|
|
1542
|
+
const thresholdCopy = Number.isFinite(minimumEngagedPlays) &&
|
|
1543
|
+
Number.isFinite(minimumListeningMinutes)
|
|
1544
|
+
? `Each song comes from its own biggest year, with at least ${number(minimumEngagedPlays)} plays not skipped and ${number(minimumListeningMinutes, 1)} minutes, and never one you asked to keep out.`
|
|
1545
|
+
: "Each song comes from its own biggest year, and never one you asked to keep out.";
|
|
1546
|
+
return `<aside class="time-machine-selection" aria-label="Which years have a song"><strong>${escapeHtml(coverageCopy)}</strong><span>${escapeHtml(`${unrepresentedCopy} The years are spread evenly across your history. ${thresholdCopy}`)}</span></aside>`;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function artistRelationshipRows(items) {
|
|
1550
|
+
if (items.length === 0) {
|
|
1551
|
+
return '<p class="empty">No artist has shown up across enough years yet.</p>';
|
|
1552
|
+
}
|
|
1553
|
+
return `<ol class="relationship-list">${items
|
|
1554
|
+
.map((item) => {
|
|
1555
|
+
const activeYears = Number.isInteger(item.active_years)
|
|
1556
|
+
? item.active_years
|
|
1557
|
+
: item.last_year - item.first_year + 1;
|
|
1558
|
+
const details = [
|
|
1559
|
+
Number.isInteger(item.play_count)
|
|
1560
|
+
? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}`
|
|
1561
|
+
: null,
|
|
1562
|
+
Number.isFinite(item.listening_minutes)
|
|
1563
|
+
? listeningTime(item.listening_minutes)
|
|
1564
|
+
: null,
|
|
1565
|
+
].filter(Boolean);
|
|
1566
|
+
return `<li>
|
|
1567
|
+
<div class="relationship-head"><strong>${escapeHtml(item.name)}</strong><span>${escapeHtml(`${number(activeYears)} years, ${item.first_year} to ${item.last_year}`)}</span></div>
|
|
1568
|
+
${details.length > 0 ? `<small>${escapeHtml(details.join(" / "))}</small>` : ""}
|
|
1569
|
+
</li>`;
|
|
1570
|
+
})
|
|
1571
|
+
.join("")}</ol>`;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function yearTransitionRows(items, fallbackArtistLimit) {
|
|
1575
|
+
if (items.length === 0) {
|
|
1576
|
+
return '<p class="empty">This needs at least two years of history.</p>';
|
|
1577
|
+
}
|
|
1578
|
+
return `<ol class="transition-list">${items
|
|
1579
|
+
.map((item) => {
|
|
1580
|
+
const artistLimit = Number.isInteger(item.artist_limit)
|
|
1581
|
+
? item.artist_limit
|
|
1582
|
+
: fallbackArtistLimit;
|
|
1583
|
+
const continuity = Math.max(0, Math.min(100, item.continuity_percent));
|
|
1584
|
+
const retained = Number.isInteger(item.retained_artist_count)
|
|
1585
|
+
? item.retained_artist_count
|
|
1586
|
+
: 0;
|
|
1587
|
+
const introduced = Number.isInteger(item.new_artist_count)
|
|
1588
|
+
? item.new_artist_count
|
|
1589
|
+
: 0;
|
|
1590
|
+
const retainedArtists = item.retained_artists ?? [];
|
|
1591
|
+
const newArtists = item.new_artists ?? [];
|
|
1592
|
+
const retainedNames = retainedArtists.length > 0
|
|
1593
|
+
? `Stayed: ${naturalList(retainedArtists)}.`
|
|
1594
|
+
: "";
|
|
1595
|
+
const introducedNames = newArtists.length > 0
|
|
1596
|
+
? `New: ${naturalList(newArtists)}.`
|
|
1597
|
+
: "";
|
|
1598
|
+
return `<li>
|
|
1599
|
+
<div class="transition-head"><strong>${escapeHtml(`${item.from_year} to ${item.to_year}`)}</strong><span>${escapeHtml(`${number(continuity, 1)}% stayed`)}</span></div>
|
|
1600
|
+
<div class="transition-bar" aria-label="${escapeHtml(`${number(continuity, 1)} percent of ${item.to_year}'s top artists also ranked in ${item.from_year}`)}"><span style="width:${escapeHtml(String(continuity))}%"></span></div>
|
|
1601
|
+
<p>${escapeHtml(`${number(introduced)} new in ${item.to_year}'s top ${number(artistLimit)}, ${number(retained)} still there from ${item.from_year}.`)}</p>
|
|
1602
|
+
${retainedNames || introducedNames ? `<small>${escapeHtml(`${retainedNames} ${introducedNames}`.trim())}</small>` : ""}
|
|
1603
|
+
</li>`;
|
|
1604
|
+
})
|
|
1605
|
+
.join("")}</ol>`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function releaseDepthRows(items) {
|
|
1609
|
+
if (items.length === 0) {
|
|
1610
|
+
return '<p class="empty">No record has enough tracks played yet.</p>';
|
|
1611
|
+
}
|
|
1612
|
+
return `<ol class="release-depth-list">${items
|
|
1613
|
+
.map((item, index) => {
|
|
1614
|
+
const years = Number.isInteger(item.first_year) &&
|
|
1615
|
+
Number.isInteger(item.last_year)
|
|
1616
|
+
? item.first_year === item.last_year
|
|
1617
|
+
? `${item.first_year}`
|
|
1618
|
+
: `${item.first_year}-${item.last_year}`
|
|
1619
|
+
: null;
|
|
1620
|
+
const details = [
|
|
1621
|
+
Number.isInteger(item.distinct_tracks)
|
|
1622
|
+
? `${number(item.distinct_tracks)} tracks`
|
|
1623
|
+
: null,
|
|
1624
|
+
Number.isInteger(item.play_count)
|
|
1625
|
+
? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}`
|
|
1626
|
+
: null,
|
|
1627
|
+
listeningTime(item.listening_minutes),
|
|
1628
|
+
Number.isInteger(item.active_years) && years
|
|
1629
|
+
? `${number(item.active_years)} ${item.active_years === 1 ? "year" : "years"}, ${years}`
|
|
1630
|
+
: years,
|
|
1631
|
+
].filter(Boolean);
|
|
1632
|
+
return `<li>
|
|
1633
|
+
<span class="release-index">${String(index + 1).padStart(2, "0")}</span>
|
|
1634
|
+
<div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.artist_credit)}</p><small>${escapeHtml(details.join(" / "))}</small></div>
|
|
1635
|
+
</li>`;
|
|
1636
|
+
})
|
|
1637
|
+
.join("")}</ol>`;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function sessionShape(summary) {
|
|
1641
|
+
if (!summary) {
|
|
1642
|
+
return '<p class="empty">This needs Spotify extended streaming history.</p>';
|
|
1643
|
+
}
|
|
1644
|
+
const total = summary.session_count;
|
|
1645
|
+
const minimumPlays = summary.extended_sequence_minimum_plays ?? 5;
|
|
1646
|
+
const mix = [
|
|
1647
|
+
{ label: "1 play", value: summary.single_play_sessions ?? 0 },
|
|
1648
|
+
{ label: `2-${minimumPlays - 1} plays`, value: summary.short_sequence_sessions ?? 0 },
|
|
1649
|
+
{ label: `${minimumPlays}+ plays`, value: summary.extended_sequence_sessions ?? 0 },
|
|
1650
|
+
];
|
|
1651
|
+
const medianMinutes = listeningTime(summary.median_listening_minutes);
|
|
1652
|
+
return `<div class="session-shape">
|
|
1653
|
+
<p class="session-lede"><strong>${escapeHtml(`${number(total)} listening stretches`)}</strong><span>${escapeHtml(`${number(summary.extended_sequence_percent, 1)}% contain ${number(minimumPlays)} or more plays`)}</span></p>
|
|
1654
|
+
<div class="session-stats">
|
|
1655
|
+
<div><strong>${escapeHtml(number(summary.median_plays, 1))}</strong><span>plays in a typical stretch</span></div>
|
|
1656
|
+
<div><strong>${escapeHtml(medianMinutes ?? "Unknown")}</strong><span>a typical stretch lasts</span></div>
|
|
1657
|
+
<div><strong>${escapeHtml(number(summary.event_count))}</strong><span>plays</span></div>
|
|
1658
|
+
</div>
|
|
1659
|
+
<ol class="session-mix">${mix
|
|
1660
|
+
.map((item) => {
|
|
1661
|
+
const share = percentage(item.value, total) ?? 0;
|
|
1662
|
+
return `<li><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(`${number(item.value)} stretches / ${number(share, 1)}%`)}</span></div><div class="session-bar" aria-hidden="true"><span style="width:${escapeHtml(String(share))}%"></span></div></li>`;
|
|
1663
|
+
})
|
|
1664
|
+
.join("")}</ol>
|
|
1665
|
+
<p class="session-boundary">A break of more than ${escapeHtml(number(summary.gap_minutes))} minutes starts a new stretch. Stretches are estimated from when each song stopped.</p>
|
|
1666
|
+
</div>`;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const pulseMonthLabels = Object.freeze([
|
|
1670
|
+
["J", "January"],
|
|
1671
|
+
["F", "February"],
|
|
1672
|
+
["M", "March"],
|
|
1673
|
+
["A", "April"],
|
|
1674
|
+
["M", "May"],
|
|
1675
|
+
["J", "June"],
|
|
1676
|
+
["J", "July"],
|
|
1677
|
+
["A", "August"],
|
|
1678
|
+
["S", "September"],
|
|
1679
|
+
["O", "October"],
|
|
1680
|
+
["N", "November"],
|
|
1681
|
+
["D", "December"],
|
|
1682
|
+
]);
|
|
1683
|
+
|
|
1684
|
+
function listeningPulseLevel(listeningMinutes, peakListeningMinutes) {
|
|
1685
|
+
if (listeningMinutes <= 0 || peakListeningMinutes <= 0) return 0;
|
|
1686
|
+
return Math.max(
|
|
1687
|
+
1,
|
|
1688
|
+
Math.min(4, Math.ceil(Math.sqrt(listeningMinutes / peakListeningMinutes) * 4)),
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function monthlyActivityGrid(activity) {
|
|
1693
|
+
if (!activity) return "";
|
|
1694
|
+
const byMonth = new Map(activity.months.map((month) => [month.month, month]));
|
|
1695
|
+
const firstYear = Number(activity.first_month.slice(0, 4));
|
|
1696
|
+
const lastYear = Number(activity.last_month.slice(0, 4));
|
|
1697
|
+
const monthHeaders = pulseMonthLabels
|
|
1698
|
+
.map(([short, long]) =>
|
|
1699
|
+
`<span class="pulse-month-label" title="${long}" aria-hidden="true">${short}</span>`,
|
|
1700
|
+
)
|
|
1701
|
+
.join("");
|
|
1702
|
+
const rows = [];
|
|
1703
|
+
for (let year = firstYear; year <= lastYear; year += 1) {
|
|
1704
|
+
const cells = pulseMonthLabels.map(([, long], monthIndexValue) => {
|
|
1705
|
+
const key = `${year}-${String(monthIndexValue + 1).padStart(2, "0")}`;
|
|
1706
|
+
const month = byMonth.get(key);
|
|
1707
|
+
if (!month) {
|
|
1708
|
+
return '<span class="pulse-cell is-outside" aria-hidden="true"></span>';
|
|
1709
|
+
}
|
|
1710
|
+
const level = listeningPulseLevel(
|
|
1711
|
+
month.listening_minutes,
|
|
1712
|
+
activity.peak_listening_minutes,
|
|
1713
|
+
);
|
|
1714
|
+
const description = month.event_count > 0
|
|
1715
|
+
? `${long} ${year}: ${number(month.event_count)} ${month.event_count === 1 ? "play" : "plays"}, ${number(month.listening_minutes)} minutes, ${number(month.distinct_tracks)} ${month.distinct_tracks === 1 ? "track" : "tracks"}.`
|
|
1716
|
+
: `${long} ${year}: nothing in your history.`;
|
|
1717
|
+
return `<span class="pulse-cell level-${level}" role="gridcell" title="${escapeHtml(description)}" aria-label="${escapeHtml(description)}"></span>`;
|
|
1718
|
+
}).join("");
|
|
1719
|
+
rows.push(
|
|
1720
|
+
`<span class="pulse-year" role="rowheader">${year}</span>${cells}`,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
const boundedCopy = activity.omitted_earlier_month_count > 0
|
|
1724
|
+
? `Showing the latest ${number(activity.represented_month_count)} of ${number(activity.retained_span_months)} months; ${number(activity.omitted_earlier_month_count)} earlier months are left out here.`
|
|
1725
|
+
: `Showing all ${number(activity.represented_month_count)} months.`;
|
|
1726
|
+
return `<div class="listening-pulse">
|
|
1727
|
+
<div class="pulse-summary"><strong>${escapeHtml(`${number(activity.active_month_count)} months with listening`)}</strong><span>${escapeHtml(`${activity.first_month} to ${activity.last_month} UTC`)}</span></div>
|
|
1728
|
+
<div class="listening-pulse-grid" role="grid" aria-label="Listening by month, in UTC">
|
|
1729
|
+
<span class="pulse-corner" aria-hidden="true">UTC</span>${monthHeaders}${rows.join("")}
|
|
1730
|
+
</div>
|
|
1731
|
+
<div class="pulse-legend" aria-label="Listening-minute intensity scale"><span>Less</span><i class="pulse-cell level-1"></i><i class="pulse-cell level-2"></i><i class="pulse-cell level-3"></i><i class="pulse-cell level-4"></i><span>More</span></div>
|
|
1732
|
+
<p class="pulse-boundary">${escapeHtml(`${boundedCopy} Darker means more minutes, compared with your own busiest month. A blank cell means no history was kept for that month, not that you stopped listening.`)}</p>
|
|
1733
|
+
</div>`;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function listeningSeasonMonthRange(season) {
|
|
1737
|
+
const firstMonth = Number(season.start_month.slice(5, 7));
|
|
1738
|
+
const lastMonth = Number(season.end_month.slice(5, 7));
|
|
1739
|
+
const firstLabel = pulseMonthLabels[firstMonth - 1]?.[1]?.slice(0, 3);
|
|
1740
|
+
const lastLabel = pulseMonthLabels[lastMonth - 1]?.[1]?.slice(0, 3);
|
|
1741
|
+
return firstLabel === lastLabel
|
|
1742
|
+
? `${firstLabel} UTC`
|
|
1743
|
+
: `${firstLabel}-${lastLabel} UTC`;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function listeningSeasonsPanel(activity) {
|
|
1747
|
+
if (!activity || activity.seasons.length === 0) return "";
|
|
1748
|
+
const cards = activity.seasons
|
|
1749
|
+
.map((season) => {
|
|
1750
|
+
const quarter = Number(season.key.slice(6));
|
|
1751
|
+
const heading = season.key.replace("-", " ");
|
|
1752
|
+
const partial = season.retained_month_count < 3
|
|
1753
|
+
? `${number(season.retained_month_count)} of 3 months in your history`
|
|
1754
|
+
: "a full quarter";
|
|
1755
|
+
if (season.event_count === 0) {
|
|
1756
|
+
return `<article class="listening-season-card is-empty quarter-${quarter}">
|
|
1757
|
+
<header><strong>${escapeHtml(heading)}</strong><span>${escapeHtml(listeningSeasonMonthRange(season))}</span></header>
|
|
1758
|
+
<div class="season-empty-mark" aria-hidden="true"></div>
|
|
1759
|
+
<p>Nothing in your history for these months.</p>
|
|
1760
|
+
<small>${escapeHtml(partial)}</small>
|
|
1761
|
+
</article>`;
|
|
1762
|
+
}
|
|
1763
|
+
const firstObservedShare = percentage(
|
|
1764
|
+
season.first_observed_tracks,
|
|
1765
|
+
season.distinct_tracks,
|
|
1766
|
+
) ?? 0;
|
|
1767
|
+
const signature = season.signature_track;
|
|
1768
|
+
const leading = season.leading_artist;
|
|
1769
|
+
return `<article class="listening-season-card quarter-${quarter}">
|
|
1770
|
+
<header><strong>${escapeHtml(heading)}</strong><span>${escapeHtml(listeningSeasonMonthRange(season))}</span></header>
|
|
1771
|
+
<div class="season-total"><strong>${escapeHtml(listeningTime(season.listening_minutes) ?? "Unknown time")}</strong><span>${escapeHtml(`${number(season.event_count)} ${season.event_count === 1 ? "play" : "plays"} / ${number(season.distinct_tracks)} ${season.distinct_tracks === 1 ? "track" : "tracks"}`)}</span></div>
|
|
1772
|
+
<div class="season-mix" aria-label="${escapeHtml(`${number(season.first_observed_tracks)} tracks new to your history and ${number(season.returning_tracks)} heard in an earlier season`)}">
|
|
1773
|
+
<span style="width:${escapeHtml(String(firstObservedShare))}%"></span>
|
|
1774
|
+
</div>
|
|
1775
|
+
<p class="season-mix-label"><span>${escapeHtml(`${number(season.first_observed_tracks)} new`)}</span><span>${escapeHtml(`${number(season.returning_tracks)} heard before`)}</span></p>
|
|
1776
|
+
<dl class="season-anchors">
|
|
1777
|
+
<div><dt>Most played artist</dt><dd>${escapeHtml(leading.name)}</dd></div>
|
|
1778
|
+
<div><dt>Top song</dt><dd>${escapeHtml(signature.label)}<span>${escapeHtml(signature.artist_credit)}</span></dd></div>
|
|
1779
|
+
</dl>
|
|
1780
|
+
<small>${escapeHtml(`${number(season.active_month_count)} of ${number(season.retained_month_count)} months with listening / ${partial}`)}</small>
|
|
1781
|
+
</article>`;
|
|
1782
|
+
})
|
|
1783
|
+
.join("");
|
|
1784
|
+
const previewCopy = activity.preview_omitted_season_count > 0
|
|
1785
|
+
? `Showing the latest ${number(activity.preview_season_count)} of ${number(activity.represented_season_count)} seasons; ${number(activity.preview_omitted_active_season_count)} earlier seasons with listening are left out here.`
|
|
1786
|
+
: `Showing all ${number(activity.represented_season_count)} seasons.`;
|
|
1787
|
+
const projectionCopy = activity.omitted_earlier_season_count > 0
|
|
1788
|
+
? ` ${number(activity.omitted_earlier_season_count)} even earlier seasons, ${number(activity.omitted_earlier_active_season_count)} of them with listening, go back further than Moondog keeps (80 seasons).`
|
|
1789
|
+
: "";
|
|
1790
|
+
return `<article class="panel listening-seasons-panel" id="listening-seasons">
|
|
1791
|
+
<div class="listening-seasons-heading"><div><span class="panel-label">Three months at a time, in UTC</span><h3>Listening Seasons</h3></div><strong>${escapeHtml(`${number(activity.active_season_count)} seasons with listening`)}</strong></div>
|
|
1792
|
+
<p class="panel-intro">What filled each quarter of the year. The top artist and song describe those months only, not your mood or what was happening in your life.</p>
|
|
1793
|
+
<div class="listening-seasons-grid">${cards}</div>
|
|
1794
|
+
<p class="listening-seasons-boundary">${escapeHtml(`${previewCopy}${projectionCopy} New means new to your history, which is not always when you found it. An empty season means no history was kept, not that you stopped listening.`)}</p>
|
|
1795
|
+
</article>`;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function crossFormatIdentityCoverage(coverage) {
|
|
1799
|
+
const links = coverage.cross_format_track_links;
|
|
1800
|
+
const events = coverage.cross_format_linked_events;
|
|
1801
|
+
const ambiguousTracks = coverage.cross_format_ambiguous_tracks;
|
|
1802
|
+
const ambiguousEvents = coverage.cross_format_ambiguous_events;
|
|
1803
|
+
const appliedLinks = Number.isInteger(links) ? links : 0;
|
|
1804
|
+
const withheldTracks = Number.isInteger(ambiguousTracks)
|
|
1805
|
+
? ambiguousTracks
|
|
1806
|
+
: 0;
|
|
1807
|
+
if (appliedLinks < 1 && withheldTracks < 1) return "";
|
|
1808
|
+
const summary = appliedLinks > 0
|
|
1809
|
+
? `${number(appliedLinks)} ${appliedLinks === 1 ? "track" : "tracks"} matched across your two Spotify exports.`
|
|
1810
|
+
: `${number(withheldTracks)} ${withheldTracks === 1 ? "track was" : "tracks were"} kept apart because the match was unclear.`;
|
|
1811
|
+
const detail = [];
|
|
1812
|
+
if (appliedLinks > 0) {
|
|
1813
|
+
const linkedEventNoun = events === 1 ? "play" : "plays";
|
|
1814
|
+
detail.push(
|
|
1815
|
+
`Plays that appear in both exports line up exactly, so ${number(events)} ${linkedEventNoun} now count toward the same songs.`,
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
if (withheldTracks > 0) {
|
|
1819
|
+
const ambiguousTrackNoun = withheldTracks === 1 ? "track" : "tracks";
|
|
1820
|
+
const ambiguousEventNoun = ambiguousEvents === 1 ? "play" : "plays";
|
|
1821
|
+
detail.push(
|
|
1822
|
+
`${number(withheldTracks)} ${ambiguousTrackNoun} with ${number(ambiguousEvents)} ${ambiguousEventNoun} could match more than one song, so they stay apart.`,
|
|
1823
|
+
);
|
|
1824
|
+
} else {
|
|
1825
|
+
detail.push("Anything unclear stays apart.");
|
|
1826
|
+
}
|
|
1827
|
+
detail.push("Your original history is unchanged.");
|
|
1828
|
+
return `<aside class="identity-coverage" aria-label="Matching across Spotify exports"><strong>${escapeHtml(summary)}</strong><span>${escapeHtml(detail.join(" "))}</span></aside>`;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
export function renderTasteprintHtml(profile, options = {}) {
|
|
1832
|
+
const view = createTasteprintView(profile, options);
|
|
1833
|
+
const syntheticDemo = view.synthetic_demo === true;
|
|
1834
|
+
const coverage = view.coverage;
|
|
1835
|
+
const context = view.behavior.context;
|
|
1836
|
+
const profiledEvents =
|
|
1837
|
+
context.effective_events_profiled ?? coverage.profiled_listening_events;
|
|
1838
|
+
const resolvedShare = percentage(
|
|
1839
|
+
coverage.resolved_listening_tracks,
|
|
1840
|
+
coverage.listening_tracks,
|
|
1841
|
+
);
|
|
1842
|
+
const generatedLabel = dateLabel(view.generated_at);
|
|
1843
|
+
const rangeLabel = view.timeline.earliest && view.timeline.latest
|
|
1844
|
+
? `${dateLabel(view.timeline.earliest)} to ${dateLabel(view.timeline.latest)}`
|
|
1845
|
+
: "Your history";
|
|
1846
|
+
const recentWindow = context.recent_window_days ?? 90;
|
|
1847
|
+
const rediscoveryQuietDays =
|
|
1848
|
+
context.rediscovery_quiet_days ?? recentWindow;
|
|
1849
|
+
const listenerCorrectionCount =
|
|
1850
|
+
view.deliberate.listener_preferences.length +
|
|
1851
|
+
view.deliberate.listener_avoids.length;
|
|
1852
|
+
const hasProfileChoiceSignals = [
|
|
1853
|
+
view.deliberate.strong_preferences,
|
|
1854
|
+
view.deliberate.saved_tracks,
|
|
1855
|
+
view.deliberate.playlist_anchors,
|
|
1856
|
+
view.deliberate.followed_artists,
|
|
1857
|
+
view.deliberate.saved_albums,
|
|
1858
|
+
view.search_intent,
|
|
1859
|
+
].some((items) => items.length > 0);
|
|
1860
|
+
const hasDeliberateChoiceSignals =
|
|
1861
|
+
listenerCorrectionCount > 0 || hasProfileChoiceSignals;
|
|
1862
|
+
const hasFacetSignals =
|
|
1863
|
+
view.facets.artists.length > 0 || view.facets.genres.length > 0;
|
|
1864
|
+
const hasProviderSnapshot = [
|
|
1865
|
+
view.provider_snapshot.artists,
|
|
1866
|
+
view.provider_snapshot.tracks,
|
|
1867
|
+
view.provider_snapshot.genres,
|
|
1868
|
+
view.provider_snapshot.highlights,
|
|
1869
|
+
view.provider_snapshot.metrics,
|
|
1870
|
+
].some((items) => items.length > 0);
|
|
1871
|
+
const hasListeningPatterns =
|
|
1872
|
+
view.behavior.release_depth.length > 0 ||
|
|
1873
|
+
view.behavior.session_summary !== null ||
|
|
1874
|
+
view.behavior.back_to_back_tracks.length > 0;
|
|
1875
|
+
const heroDimensions = [
|
|
1876
|
+
"what you know by heart",
|
|
1877
|
+
hasDeliberateChoiceSignals ? "what you chose to keep" : null,
|
|
1878
|
+
"what you play lately",
|
|
1879
|
+
view.behavior.time_capsule_tracks.length > 0
|
|
1880
|
+
? "one song for each year"
|
|
1881
|
+
: null,
|
|
1882
|
+
view.behavior.monthly_activity ? "your months" : null,
|
|
1883
|
+
view.behavior.listening_seasons ? "your seasons" : null,
|
|
1884
|
+
view.behavior.artist_relationships.length > 0 ||
|
|
1885
|
+
view.behavior.year_transitions.length > 0
|
|
1886
|
+
? "who stayed and who changed"
|
|
1887
|
+
: null,
|
|
1888
|
+
hasListeningPatterns ? "how you listen in a stretch" : null,
|
|
1889
|
+
view.behavior.rediscovery_tracks.length > 0
|
|
1890
|
+
? "songs worth another listen"
|
|
1891
|
+
: null,
|
|
1892
|
+
hasProviderSnapshot ? "what Spotify says about you" : null,
|
|
1893
|
+
].filter(Boolean);
|
|
1894
|
+
const heroCopy = `A reading of ${naturalList(heroDimensions)}. No single number gets to define your taste.`;
|
|
1895
|
+
const sectionLinks = [
|
|
1896
|
+
{ href: "#taste-shape", label: "Shine On" },
|
|
1897
|
+
...(view.behavior.monthly_activity
|
|
1898
|
+
? [{ href: "#listening-pulse", label: "Months" }]
|
|
1899
|
+
: []),
|
|
1900
|
+
...(view.behavior.listening_seasons
|
|
1901
|
+
? [{ href: "#listening-seasons", label: "Seasons" }]
|
|
1902
|
+
: []),
|
|
1903
|
+
{ href: "#listening-arc", label: "Year by year" },
|
|
1904
|
+
...(view.behavior.artist_relationships.length > 0 ||
|
|
1905
|
+
view.behavior.year_transitions.length > 0
|
|
1906
|
+
? [{ href: "#continuity", label: "Stayed and changed" }]
|
|
1907
|
+
: []),
|
|
1908
|
+
...(hasListeningPatterns
|
|
1909
|
+
? [{ href: "#listening-patterns", label: "Stretches" }]
|
|
1910
|
+
: []),
|
|
1911
|
+
{ href: "#tracks-that-stay", label: "Quiet and back" },
|
|
1912
|
+
...(listenerCorrectionCount > 0
|
|
1913
|
+
? [{ href: "#listener-corrections", label: "What you told me" }]
|
|
1914
|
+
: []),
|
|
1915
|
+
...(hasProfileChoiceSignals || hasFacetSignals
|
|
1916
|
+
? [{ href: "#profile-evidence", label: hasProfileChoiceSignals ? "Kept on purpose" : "Artists and genres" }]
|
|
1917
|
+
: []),
|
|
1918
|
+
{ href: "#playback-flow", label: "How you listen" },
|
|
1919
|
+
...(hasProviderSnapshot
|
|
1920
|
+
? [{ href: "#provider-snapshot", label: "Spotify's view" }]
|
|
1921
|
+
: []),
|
|
1922
|
+
{ href: "#interpretation-boundaries", label: "The dark side" },
|
|
1923
|
+
];
|
|
1924
|
+
const sectionNavigator = `<nav class="section-nav" aria-label="Tasteprint sections">
|
|
1925
|
+
<span class="section-nav-label">In this report</span>
|
|
1926
|
+
<div class="section-nav-links">${sectionLinks.map((item) => `<a href="${item.href}">${item.label}</a>`).join("")}</div>
|
|
1927
|
+
</nav>`;
|
|
1928
|
+
|
|
1929
|
+
const choicePanels = [
|
|
1930
|
+
view.deliberate.strong_preferences.length > 0
|
|
1931
|
+
? `<article class="panel"><span class="panel-label">Loved, rated, or saved</span><h3>Favorites</h3>${signalList(view.deliberate.strong_preferences)}</article>`
|
|
1932
|
+
: "",
|
|
1933
|
+
view.deliberate.saved_tracks.length > 0
|
|
1934
|
+
? `<article class="panel"><span class="panel-label">Your library</span><h3>Saved songs</h3>${trackRows(view.deliberate.saved_tracks)}</article>`
|
|
1935
|
+
: "",
|
|
1936
|
+
view.deliberate.playlist_anchors.length > 0
|
|
1937
|
+
? `<article class="panel"><span class="panel-label">Your playlists</span><h3>On your playlists</h3>${trackRows(view.deliberate.playlist_anchors)}</article>`
|
|
1938
|
+
: "",
|
|
1939
|
+
view.deliberate.followed_artists.length > 0
|
|
1940
|
+
? `<article class="panel"><span class="panel-label">Your follows</span><h3>Artists you follow</h3>${nameRows(view.deliberate.followed_artists)}</article>`
|
|
1941
|
+
: "",
|
|
1942
|
+
view.deliberate.saved_albums.length > 0
|
|
1943
|
+
? `<article class="panel"><span class="panel-label">Your library</span><h3>Saved albums</h3>${trackRows(view.deliberate.saved_albums)}</article>`
|
|
1944
|
+
: "",
|
|
1945
|
+
view.search_intent.length > 0
|
|
1946
|
+
? `<article class="panel"><span class="panel-label">Searches you acted on</span><h3>What you searched for</h3>${searchRows(view.search_intent)}</article>`
|
|
1947
|
+
: "",
|
|
1948
|
+
].filter(Boolean);
|
|
1949
|
+
const facetBlocks = [
|
|
1950
|
+
view.facets.artists.length > 0
|
|
1951
|
+
? `<div class="facet-block"><h3>Artists</h3>${tagCloud(view.facets.artists)}</div>`
|
|
1952
|
+
: "",
|
|
1953
|
+
view.facets.genres.length > 0
|
|
1954
|
+
? `<div class="facet-block"><h3>Genres</h3>${tagCloud(view.facets.genres)}</div>`
|
|
1955
|
+
: "",
|
|
1956
|
+
].filter(Boolean);
|
|
1957
|
+
const profileEvidenceHeading = hasProfileChoiceSignals
|
|
1958
|
+
? hasFacetSignals
|
|
1959
|
+
? "Kept on purpose"
|
|
1960
|
+
: "Kept on purpose"
|
|
1961
|
+
: "Artists and genres";
|
|
1962
|
+
const profileEvidenceCopy = hasProfileChoiceSignals
|
|
1963
|
+
? hasFacetSignals
|
|
1964
|
+
? "Songs you saved, loved, followed, put on playlists, or searched for. These are choices you made, kept apart from what you only played. The artist and genre summary sits apart too, because it is a summary, not a choice."
|
|
1965
|
+
: "Songs you saved, loved, followed, put on playlists, or searched for. These are choices you made, kept apart from what you only played."
|
|
1966
|
+
: "A short summary of the artists and genres in your profile. It describes your listening, not a choice you made.";
|
|
1967
|
+
const profileEvidenceSection =
|
|
1968
|
+
hasProfileChoiceSignals || hasFacetSignals
|
|
1969
|
+
? `<section class="section" id="profile-evidence">
|
|
1970
|
+
<div class="section-heading"><h2>${profileEvidenceHeading}</h2><p>${profileEvidenceCopy}</p></div>
|
|
1971
|
+
${facetBlocks.length > 0 ? `<article class="panel profile-facets"><span class="panel-label">A summary, not a choice</span>${facetBlocks.join("")}</article>` : ""}
|
|
1972
|
+
${choicePanels.length > 0 ? `<div class="adaptive-grid"${facetBlocks.length > 0 ? ' style="margin-top:18px"' : ""}>${choicePanels.join("")}</div>` : ""}
|
|
1973
|
+
</section>`
|
|
1974
|
+
: "";
|
|
1975
|
+
|
|
1976
|
+
const providerPanels = [
|
|
1977
|
+
view.provider_snapshot.artists.length > 0
|
|
1978
|
+
? `<article class="panel"><span class="panel-label">Spotify's ranking</span><h3>Top artists</h3>${providerRows(view.provider_snapshot.artists, "artist")}</article>`
|
|
1979
|
+
: "",
|
|
1980
|
+
view.provider_snapshot.tracks.length > 0
|
|
1981
|
+
? `<article class="panel"><span class="panel-label">Spotify's ranking</span><h3>Top songs</h3>${providerRows(view.provider_snapshot.tracks, "track")}</article>`
|
|
1982
|
+
: "",
|
|
1983
|
+
].filter(Boolean);
|
|
1984
|
+
const providerCards = snapshotCards(view);
|
|
1985
|
+
const providerSection = hasProviderSnapshot
|
|
1986
|
+
? `<section class="section" id="provider-snapshot">
|
|
1987
|
+
<div class="section-heading"><h2>Spotify's view</h2><p>What Wrapped, Taste Profile, and Sound Capsule say about you, quoted as Spotify wrote it. Moondog does not treat it as something you said.</p></div>
|
|
1988
|
+
<p class="provider-intro">It stays apart from your own history and choices. A ranking can sum up a period, but it cannot stand in for what you actually played.</p>
|
|
1989
|
+
${providerPanels.length > 0 ? `<div class="adaptive-grid">${providerPanels.join("")}</div>` : ""}
|
|
1990
|
+
${view.provider_snapshot.genres.length > 0 ? `<div class="facet-block"${providerPanels.length > 0 ? ' style="margin-top:18px"' : ""}><h3>Spotify's genres</h3>${tagCloud(view.provider_snapshot.genres.map((item) => `${item.name}${Number.isInteger(item.rank) ? ` #${item.rank}` : ""}`))}</div>` : ""}
|
|
1991
|
+
${providerCards}
|
|
1992
|
+
</section>`
|
|
1993
|
+
: "";
|
|
1994
|
+
|
|
1995
|
+
return `<!doctype html>
|
|
1996
|
+
<html lang="en">
|
|
1997
|
+
<head>
|
|
1998
|
+
<meta charset="utf-8">
|
|
1999
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2000
|
+
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
2001
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
|
2002
|
+
<meta name="color-scheme" content="light">
|
|
2003
|
+
<title>${syntheticDemo ? "Moondog Synthetic Tasteprint Demo" : "Private Moondog Tasteprint"}</title>
|
|
2004
|
+
<style>
|
|
2005
|
+
:root {
|
|
2006
|
+
--paper: #eee8de;
|
|
2007
|
+
--paper-deep: #e3d9cc;
|
|
2008
|
+
--card: #f8f4ed;
|
|
2009
|
+
--ink: #1b1b19;
|
|
2010
|
+
--muted: #68625a;
|
|
2011
|
+
--line: #c8bdaf;
|
|
2012
|
+
--line-strong: #9f9384;
|
|
2013
|
+
--accent: #6f8ca8;
|
|
2014
|
+
--accent-soft: #d7e3ed;
|
|
2015
|
+
--night: #1b1b19;
|
|
2016
|
+
--night-soft: #252522;
|
|
2017
|
+
--shadow: 0 18px 50px rgba(39, 31, 24, 0.09);
|
|
2018
|
+
color: var(--ink);
|
|
2019
|
+
background: var(--paper);
|
|
2020
|
+
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
* { box-sizing: border-box; }
|
|
2024
|
+
|
|
2025
|
+
html { min-width: 320px; scroll-behavior: smooth; }
|
|
2026
|
+
|
|
2027
|
+
html:has(#taste-shape:target),
|
|
2028
|
+
html:has(#time-machine:target),
|
|
2029
|
+
html:has(#listening-pulse:target),
|
|
2030
|
+
html:has(#listening-seasons:target),
|
|
2031
|
+
html:has(#listening-patterns:target) { scrollbar-width: none; }
|
|
2032
|
+
html:has(#taste-shape:target)::-webkit-scrollbar,
|
|
2033
|
+
html:has(#time-machine:target)::-webkit-scrollbar,
|
|
2034
|
+
html:has(#listening-pulse:target)::-webkit-scrollbar,
|
|
2035
|
+
html:has(#listening-seasons:target)::-webkit-scrollbar,
|
|
2036
|
+
html:has(#listening-patterns:target)::-webkit-scrollbar { display: none; }
|
|
2037
|
+
|
|
2038
|
+
html:has(#taste-shape:target),
|
|
2039
|
+
html:has(#time-machine:target),
|
|
2040
|
+
html:has(#listening-pulse:target),
|
|
2041
|
+
html:has(#listening-seasons:target),
|
|
2042
|
+
html:has(#listening-patterns:target) { scroll-behavior: auto; }
|
|
2043
|
+
|
|
2044
|
+
body {
|
|
2045
|
+
margin: 0;
|
|
2046
|
+
background:
|
|
2047
|
+
radial-gradient(circle at 8% 0%, rgba(255, 255, 255, 0.68), transparent 35rem),
|
|
2048
|
+
linear-gradient(180deg, var(--paper) 0%, #e8dfd3 100%);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
.shell {
|
|
2052
|
+
width: min(1220px, calc(100% - 40px));
|
|
2053
|
+
margin: 0 auto;
|
|
2054
|
+
padding: 42px 0 70px;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
.hero {
|
|
2058
|
+
position: relative;
|
|
2059
|
+
isolation: isolate;
|
|
2060
|
+
overflow: hidden;
|
|
2061
|
+
min-height: 590px;
|
|
2062
|
+
padding: clamp(34px, 6vw, 74px);
|
|
2063
|
+
border: 1px solid #0f0f0e;
|
|
2064
|
+
border-radius: 34px;
|
|
2065
|
+
color: #f5f1e9;
|
|
2066
|
+
background: var(--night);
|
|
2067
|
+
box-shadow: var(--shadow);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
.hero::before {
|
|
2071
|
+
position: absolute;
|
|
2072
|
+
z-index: -1;
|
|
2073
|
+
width: min(760px, 70vw);
|
|
2074
|
+
aspect-ratio: 1;
|
|
2075
|
+
right: min(-150px, -8vw);
|
|
2076
|
+
top: -160px;
|
|
2077
|
+
border: 1px solid rgba(245, 241, 233, 0.25);
|
|
2078
|
+
border-radius: 50%;
|
|
2079
|
+
box-shadow:
|
|
2080
|
+
0 0 0 18px rgba(245, 241, 233, 0.035),
|
|
2081
|
+
0 0 0 48px rgba(245, 241, 233, 0.026),
|
|
2082
|
+
0 0 0 84px rgba(245, 241, 233, 0.02),
|
|
2083
|
+
0 0 0 126px rgba(245, 241, 233, 0.016);
|
|
2084
|
+
content: "";
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
.hero::after {
|
|
2088
|
+
position: absolute;
|
|
2089
|
+
z-index: -1;
|
|
2090
|
+
width: 18px;
|
|
2091
|
+
height: 18px;
|
|
2092
|
+
right: clamp(100px, 17vw, 230px);
|
|
2093
|
+
top: 168px;
|
|
2094
|
+
border: 9px solid rgba(245, 241, 233, 0.12);
|
|
2095
|
+
border-radius: 50%;
|
|
2096
|
+
background: #f5f1e9;
|
|
2097
|
+
content: "";
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
.eyebrow {
|
|
2101
|
+
display: flex;
|
|
2102
|
+
gap: 9px;
|
|
2103
|
+
align-items: center;
|
|
2104
|
+
margin: 0 0 40px;
|
|
2105
|
+
font-size: 11px;
|
|
2106
|
+
font-weight: 800;
|
|
2107
|
+
letter-spacing: 0.19em;
|
|
2108
|
+
text-transform: uppercase;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
.eyebrow::before {
|
|
2112
|
+
width: 8px;
|
|
2113
|
+
height: 8px;
|
|
2114
|
+
border: 5px solid rgba(111, 140, 168, 0.25);
|
|
2115
|
+
border-radius: 50%;
|
|
2116
|
+
background: #a9bfd3;
|
|
2117
|
+
content: "";
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
h1, h2, .serif {
|
|
2121
|
+
font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
|
|
2122
|
+
font-weight: 650;
|
|
2123
|
+
letter-spacing: -0.035em;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
h1 {
|
|
2127
|
+
max-width: 760px;
|
|
2128
|
+
margin: 0;
|
|
2129
|
+
font-size: clamp(54px, 8.6vw, 112px);
|
|
2130
|
+
line-height: 0.86;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
.hero-copy {
|
|
2134
|
+
max-width: 590px;
|
|
2135
|
+
margin: 32px 0 0;
|
|
2136
|
+
color: #d0cbc3;
|
|
2137
|
+
font-size: clamp(17px, 2vw, 21px);
|
|
2138
|
+
line-height: 1.55;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
.hero-meta {
|
|
2142
|
+
display: grid;
|
|
2143
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2144
|
+
width: min(690px, 100%);
|
|
2145
|
+
margin-top: 54px;
|
|
2146
|
+
border: 1px solid rgba(245, 241, 233, 0.24);
|
|
2147
|
+
border-radius: 16px;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
.hero-meta div { min-width: 0; padding: 16px 18px; }
|
|
2151
|
+
.hero-meta div + div { border-left: 1px solid rgba(245, 241, 233, 0.18); }
|
|
2152
|
+
|
|
2153
|
+
.hero-meta span {
|
|
2154
|
+
display: block;
|
|
2155
|
+
margin-bottom: 7px;
|
|
2156
|
+
color: #aca79f;
|
|
2157
|
+
font-size: 9px;
|
|
2158
|
+
font-weight: 800;
|
|
2159
|
+
letter-spacing: 0.16em;
|
|
2160
|
+
text-transform: uppercase;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
.hero-meta strong { overflow-wrap: anywhere; font-size: 13px; }
|
|
2164
|
+
|
|
2165
|
+
.privacy {
|
|
2166
|
+
display: grid;
|
|
2167
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
2168
|
+
gap: 15px;
|
|
2169
|
+
align-items: start;
|
|
2170
|
+
margin: 24px 0;
|
|
2171
|
+
padding: 20px 22px;
|
|
2172
|
+
border: 1px solid var(--line-strong);
|
|
2173
|
+
border-radius: 18px;
|
|
2174
|
+
background: rgba(248, 244, 237, 0.72);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
.privacy-mark {
|
|
2178
|
+
display: grid;
|
|
2179
|
+
width: 36px;
|
|
2180
|
+
height: 36px;
|
|
2181
|
+
place-items: center;
|
|
2182
|
+
border-radius: 50%;
|
|
2183
|
+
color: #f5f1e9;
|
|
2184
|
+
background: var(--night);
|
|
2185
|
+
font-weight: 800;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
.privacy strong { display: block; margin: 1px 0 4px; }
|
|
2189
|
+
.privacy p { margin: 0; color: var(--muted); line-height: 1.5; }
|
|
2190
|
+
|
|
2191
|
+
.overview {
|
|
2192
|
+
display: grid;
|
|
2193
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
2194
|
+
gap: 14px;
|
|
2195
|
+
margin: 24px 0;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
.identity-coverage {
|
|
2199
|
+
display: grid;
|
|
2200
|
+
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr);
|
|
2201
|
+
gap: 20px;
|
|
2202
|
+
margin: -8px 0 28px;
|
|
2203
|
+
padding: 18px 20px;
|
|
2204
|
+
border-left: 4px solid var(--accent);
|
|
2205
|
+
color: var(--muted);
|
|
2206
|
+
background: var(--accent-soft);
|
|
2207
|
+
line-height: 1.5;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
.identity-coverage strong,
|
|
2211
|
+
.identity-coverage span {
|
|
2212
|
+
min-width: 0;
|
|
2213
|
+
overflow-wrap: anywhere;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
.metric-card {
|
|
2217
|
+
min-width: 0;
|
|
2218
|
+
min-height: 178px;
|
|
2219
|
+
padding: 24px;
|
|
2220
|
+
border: 1px solid var(--line);
|
|
2221
|
+
border-radius: 20px;
|
|
2222
|
+
background: var(--card);
|
|
2223
|
+
box-shadow: 0 10px 30px rgba(37, 30, 24, 0.045);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
.metric-card strong {
|
|
2227
|
+
display: block;
|
|
2228
|
+
margin-bottom: 30px;
|
|
2229
|
+
overflow-wrap: anywhere;
|
|
2230
|
+
font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
|
|
2231
|
+
font-size: clamp(37px, 4.2vw, 55px);
|
|
2232
|
+
font-weight: 650;
|
|
2233
|
+
letter-spacing: -0.04em;
|
|
2234
|
+
line-height: 0.95;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
.metric-card span {
|
|
2238
|
+
display: block;
|
|
2239
|
+
font-size: 12px;
|
|
2240
|
+
font-weight: 800;
|
|
2241
|
+
letter-spacing: 0.09em;
|
|
2242
|
+
text-transform: uppercase;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
.metric-card small { display: block; margin-top: 7px; color: var(--muted); line-height: 1.4; }
|
|
2246
|
+
|
|
2247
|
+
.section-nav {
|
|
2248
|
+
display: grid;
|
|
2249
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
2250
|
+
gap: 18px;
|
|
2251
|
+
align-items: center;
|
|
2252
|
+
margin: 24px 0;
|
|
2253
|
+
padding: 15px 16px 15px 20px;
|
|
2254
|
+
border: 1px solid var(--line-strong);
|
|
2255
|
+
border-radius: 18px;
|
|
2256
|
+
background: rgba(248, 244, 237, 0.82);
|
|
2257
|
+
box-shadow: 0 10px 30px rgba(37, 30, 24, 0.045);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
.section-nav-label {
|
|
2261
|
+
color: var(--muted);
|
|
2262
|
+
font-size: 10px;
|
|
2263
|
+
font-weight: 800;
|
|
2264
|
+
letter-spacing: 0.13em;
|
|
2265
|
+
text-transform: uppercase;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
.section-nav-links {
|
|
2269
|
+
display: flex;
|
|
2270
|
+
min-width: 0;
|
|
2271
|
+
flex-wrap: wrap;
|
|
2272
|
+
gap: 7px;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
.section-nav a {
|
|
2276
|
+
display: inline-flex;
|
|
2277
|
+
min-height: 36px;
|
|
2278
|
+
align-items: center;
|
|
2279
|
+
padding: 8px 12px;
|
|
2280
|
+
border: 1px solid var(--line);
|
|
2281
|
+
border-radius: 999px;
|
|
2282
|
+
color: var(--ink);
|
|
2283
|
+
background: #fbf8f2;
|
|
2284
|
+
font-size: 11px;
|
|
2285
|
+
font-weight: 750;
|
|
2286
|
+
text-decoration: none;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
.section-nav a:hover { border-color: var(--ink); }
|
|
2290
|
+
.section-nav a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
2291
|
+
|
|
2292
|
+
.section {
|
|
2293
|
+
min-width: 0;
|
|
2294
|
+
margin-top: 24px;
|
|
2295
|
+
scroll-margin-top: 24px;
|
|
2296
|
+
padding: clamp(26px, 4.5vw, 54px);
|
|
2297
|
+
border: 1px solid var(--line);
|
|
2298
|
+
border-radius: 26px;
|
|
2299
|
+
background: rgba(248, 244, 237, 0.92);
|
|
2300
|
+
box-shadow: var(--shadow);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
.section.dark { color: #f5f1e9; background: var(--night); border-color: #10100f; }
|
|
2304
|
+
|
|
2305
|
+
.demo-chip {
|
|
2306
|
+
display: inline-flex;
|
|
2307
|
+
margin: 0 0 22px;
|
|
2308
|
+
padding: 7px 10px;
|
|
2309
|
+
border: 1px solid #5f7182;
|
|
2310
|
+
border-radius: 999px;
|
|
2311
|
+
color: #c8d9e8;
|
|
2312
|
+
background: rgba(111, 140, 168, 0.12);
|
|
2313
|
+
font-size: 9px;
|
|
2314
|
+
font-weight: 850;
|
|
2315
|
+
letter-spacing: 0.14em;
|
|
2316
|
+
text-transform: uppercase;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
.section-heading {
|
|
2320
|
+
display: grid;
|
|
2321
|
+
grid-template-columns: minmax(0, 1fr) minmax(220px, 360px);
|
|
2322
|
+
gap: 34px;
|
|
2323
|
+
align-items: end;
|
|
2324
|
+
margin-bottom: 38px;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
.section-heading h2 {
|
|
2328
|
+
margin: 0;
|
|
2329
|
+
font-size: clamp(40px, 5.5vw, 68px);
|
|
2330
|
+
line-height: 0.98;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
.section-heading p { margin: 0; color: var(--muted); line-height: 1.55; }
|
|
2334
|
+
.dark .section-heading p { color: #bbb5ad; }
|
|
2335
|
+
|
|
2336
|
+
.two-column {
|
|
2337
|
+
display: grid;
|
|
2338
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
2339
|
+
gap: 18px;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
.three-column {
|
|
2343
|
+
display: grid;
|
|
2344
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2345
|
+
gap: 12px;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
.adaptive-grid {
|
|
2349
|
+
display: grid;
|
|
2350
|
+
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
|
2351
|
+
gap: 12px;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
.panel {
|
|
2355
|
+
min-width: 0;
|
|
2356
|
+
padding: 28px;
|
|
2357
|
+
border: 1px solid var(--line);
|
|
2358
|
+
border-radius: 20px;
|
|
2359
|
+
background: #fbf8f2;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
.dark .panel { border-color: #44433e; background: var(--night-soft); }
|
|
2363
|
+
|
|
2364
|
+
.panel-label {
|
|
2365
|
+
display: block;
|
|
2366
|
+
margin-bottom: 8px;
|
|
2367
|
+
color: var(--accent);
|
|
2368
|
+
font-size: 10px;
|
|
2369
|
+
font-weight: 850;
|
|
2370
|
+
letter-spacing: 0.15em;
|
|
2371
|
+
text-transform: uppercase;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
.panel h3 { margin: 0 0 25px; font-size: 23px; }
|
|
2375
|
+
|
|
2376
|
+
.rediscovery-panel {
|
|
2377
|
+
margin-bottom: 18px;
|
|
2378
|
+
border-color: #a8bac9;
|
|
2379
|
+
background: linear-gradient(135deg, #edf3f7 0%, #fbf8f2 72%);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
.time-capsule-panel {
|
|
2383
|
+
margin-top: 18px;
|
|
2384
|
+
border-color: #c4ad88;
|
|
2385
|
+
background: linear-gradient(135deg, #f5ecdc 0%, #fbf8f2 72%);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
.panel-intro {
|
|
2389
|
+
max-width: 760px;
|
|
2390
|
+
margin: -12px 0 24px;
|
|
2391
|
+
color: var(--muted);
|
|
2392
|
+
line-height: 1.55;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
.time-machine-selection {
|
|
2396
|
+
display: grid;
|
|
2397
|
+
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
|
|
2398
|
+
gap: 18px;
|
|
2399
|
+
margin: -5px 0 24px;
|
|
2400
|
+
padding: 15px 17px;
|
|
2401
|
+
border: 1px solid rgba(111, 140, 168, 0.42);
|
|
2402
|
+
border-radius: 14px;
|
|
2403
|
+
background: rgba(215, 227, 237, 0.44);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
.time-machine-selection strong {
|
|
2407
|
+
color: #4f708f;
|
|
2408
|
+
font-size: 12px;
|
|
2409
|
+
line-height: 1.45;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
.time-machine-selection span {
|
|
2413
|
+
min-width: 0;
|
|
2414
|
+
color: var(--muted);
|
|
2415
|
+
font-size: 12px;
|
|
2416
|
+
line-height: 1.55;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
.relationship-list, .transition-list {
|
|
2420
|
+
margin: 0;
|
|
2421
|
+
padding: 0;
|
|
2422
|
+
list-style: none;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
.relationship-list li, .transition-list li {
|
|
2426
|
+
min-width: 0;
|
|
2427
|
+
padding: 16px 0;
|
|
2428
|
+
border-top: 1px solid var(--line);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
.relationship-list li:first-child, .transition-list li:first-child {
|
|
2432
|
+
padding-top: 0;
|
|
2433
|
+
border-top: 0;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
.relationship-head, .transition-head {
|
|
2437
|
+
display: flex;
|
|
2438
|
+
min-width: 0;
|
|
2439
|
+
align-items: baseline;
|
|
2440
|
+
justify-content: space-between;
|
|
2441
|
+
gap: 14px;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
.relationship-head strong, .transition-head strong {
|
|
2445
|
+
min-width: 0;
|
|
2446
|
+
overflow-wrap: anywhere;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
.relationship-head span, .transition-head span {
|
|
2450
|
+
flex: 0 0 auto;
|
|
2451
|
+
color: var(--accent);
|
|
2452
|
+
font-size: 10px;
|
|
2453
|
+
font-weight: 800;
|
|
2454
|
+
letter-spacing: 0.04em;
|
|
2455
|
+
text-transform: uppercase;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
.relationship-list small, .transition-list small {
|
|
2459
|
+
display: block;
|
|
2460
|
+
margin-top: 6px;
|
|
2461
|
+
color: var(--muted);
|
|
2462
|
+
line-height: 1.45;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
.transition-list p {
|
|
2466
|
+
margin: 8px 0 0;
|
|
2467
|
+
color: var(--muted);
|
|
2468
|
+
font-size: 12px;
|
|
2469
|
+
line-height: 1.5;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
.transition-bar {
|
|
2473
|
+
height: 7px;
|
|
2474
|
+
margin-top: 11px;
|
|
2475
|
+
overflow: hidden;
|
|
2476
|
+
border-radius: 99px;
|
|
2477
|
+
background: #ddd5ca;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
.transition-bar span {
|
|
2481
|
+
display: block;
|
|
2482
|
+
height: 100%;
|
|
2483
|
+
border-radius: inherit;
|
|
2484
|
+
background: var(--accent);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
.pattern-section {
|
|
2488
|
+
background:
|
|
2489
|
+
radial-gradient(circle at 100% 0%, rgba(111, 140, 168, 0.16), transparent 28rem),
|
|
2490
|
+
rgba(248, 244, 237, 0.94);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
.pattern-demo-chip {
|
|
2494
|
+
display: inline-flex;
|
|
2495
|
+
margin: 0 0 22px;
|
|
2496
|
+
padding: 7px 10px;
|
|
2497
|
+
border: 1px solid #a8bac9;
|
|
2498
|
+
border-radius: 999px;
|
|
2499
|
+
color: #4f708f;
|
|
2500
|
+
background: rgba(237, 243, 247, 0.86);
|
|
2501
|
+
font-size: 9px;
|
|
2502
|
+
font-weight: 850;
|
|
2503
|
+
letter-spacing: 0.14em;
|
|
2504
|
+
text-transform: uppercase;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
.pattern-grid {
|
|
2508
|
+
align-items: start;
|
|
2509
|
+
grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr));
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
.session-panel {
|
|
2513
|
+
border-color: #a8bac9;
|
|
2514
|
+
background: linear-gradient(145deg, #edf3f7 0%, #fbf8f2 72%);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
.session-shape { min-width: 0; }
|
|
2518
|
+
|
|
2519
|
+
.session-lede {
|
|
2520
|
+
display: flex;
|
|
2521
|
+
min-width: 0;
|
|
2522
|
+
align-items: baseline;
|
|
2523
|
+
justify-content: space-between;
|
|
2524
|
+
gap: 14px;
|
|
2525
|
+
margin: 0 0 18px;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
.session-lede strong { min-width: 0; overflow-wrap: anywhere; font-size: 17px; }
|
|
2529
|
+
.session-lede span { flex: 0 1 auto; color: #4f708f; font-size: 11px; font-weight: 800; text-align: right; }
|
|
2530
|
+
|
|
2531
|
+
.session-stats {
|
|
2532
|
+
display: grid;
|
|
2533
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2534
|
+
gap: 8px;
|
|
2535
|
+
margin-bottom: 20px;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
.session-stats div {
|
|
2539
|
+
min-width: 0;
|
|
2540
|
+
padding: 14px;
|
|
2541
|
+
border: 1px solid rgba(111, 140, 168, 0.28);
|
|
2542
|
+
border-radius: 12px;
|
|
2543
|
+
background: rgba(255, 255, 255, 0.48);
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
.session-stats strong { display: block; overflow-wrap: anywhere; font: 650 25px Iowan Old Style, Baskerville, serif; }
|
|
2547
|
+
.session-stats span { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
2548
|
+
|
|
2549
|
+
.session-mix, .release-depth-list {
|
|
2550
|
+
margin: 0;
|
|
2551
|
+
padding: 0;
|
|
2552
|
+
list-style: none;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
.session-mix li { padding: 12px 0; border-top: 1px solid var(--line); }
|
|
2556
|
+
.session-mix li:first-child { padding-top: 0; border-top: 0; }
|
|
2557
|
+
.session-mix li > div:first-child { display: flex; min-width: 0; justify-content: space-between; gap: 12px; }
|
|
2558
|
+
.session-mix strong { min-width: 0; overflow-wrap: anywhere; font-size: 12px; }
|
|
2559
|
+
.session-mix li > div:first-child span { flex: 0 0 auto; color: var(--muted); font-size: 10px; }
|
|
2560
|
+
|
|
2561
|
+
.session-bar { height: 6px; margin-top: 8px; overflow: hidden; border-radius: 99px; background: #d5dce2; }
|
|
2562
|
+
.session-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
|
|
2563
|
+
|
|
2564
|
+
.session-boundary { margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
|
2565
|
+
|
|
2566
|
+
.release-depth-list li {
|
|
2567
|
+
display: grid;
|
|
2568
|
+
grid-template-columns: 34px minmax(0, 1fr);
|
|
2569
|
+
gap: 13px;
|
|
2570
|
+
min-width: 0;
|
|
2571
|
+
padding: 16px 0;
|
|
2572
|
+
border-top: 1px solid var(--line);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
.release-depth-list li:first-child { padding-top: 0; border-top: 0; }
|
|
2576
|
+
.release-index { color: var(--muted); font: 750 10px ui-monospace, monospace; }
|
|
2577
|
+
.release-depth-list li > div { min-width: 0; }
|
|
2578
|
+
.release-depth-list strong { display: block; overflow-wrap: anywhere; }
|
|
2579
|
+
.release-depth-list p { margin: 4px 0; color: var(--muted); font-size: 13px; }
|
|
2580
|
+
.release-depth-list small { color: var(--muted); line-height: 1.45; }
|
|
2581
|
+
|
|
2582
|
+
.rank-list, .track-list, .provider-list, .signal-list, .tags {
|
|
2583
|
+
margin: 0;
|
|
2584
|
+
padding: 0;
|
|
2585
|
+
list-style: none;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
.rank-list li {
|
|
2589
|
+
display: grid;
|
|
2590
|
+
grid-template-columns: 28px minmax(0, 1fr);
|
|
2591
|
+
gap: 14px;
|
|
2592
|
+
padding: 15px 0;
|
|
2593
|
+
border-top: 1px solid var(--line);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
.dark .rank-list li { border-color: #44433e; }
|
|
2597
|
+
.rank-list li:first-child { padding-top: 0; border-top: 0; }
|
|
2598
|
+
|
|
2599
|
+
.rank-number { padding-top: 2px; color: var(--muted); font: 700 10px ui-monospace, monospace; }
|
|
2600
|
+
.dark .rank-number { color: #918c84; }
|
|
2601
|
+
|
|
2602
|
+
.rank-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
|
|
2603
|
+
.rank-heading strong { min-width: 0; overflow-wrap: anywhere; }
|
|
2604
|
+
.rank-heading span { flex: 0 0 auto; color: var(--muted); font-size: 9px; text-transform: uppercase; }
|
|
2605
|
+
.dark .rank-heading span { color: #928d85; }
|
|
2606
|
+
|
|
2607
|
+
.bar { height: 5px; margin: 9px 0 7px; overflow: hidden; border-radius: 99px; background: #ddd5ca; }
|
|
2608
|
+
.bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
|
|
2609
|
+
.dark .bar { background: #3e3d39; }
|
|
2610
|
+
.rank-content small { color: var(--muted); }
|
|
2611
|
+
.dark .rank-content small { color: #aaa49c; }
|
|
2612
|
+
|
|
2613
|
+
.listening-pulse-panel {
|
|
2614
|
+
margin-bottom: 18px;
|
|
2615
|
+
border-color: #a8bac9;
|
|
2616
|
+
background:
|
|
2617
|
+
radial-gradient(circle at 100% 0%, rgba(111, 140, 168, 0.16), transparent 30rem),
|
|
2618
|
+
linear-gradient(145deg, #edf3f7 0%, #fbf8f2 72%);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
.listening-pulse { min-width: 0; }
|
|
2622
|
+
|
|
2623
|
+
.pulse-summary {
|
|
2624
|
+
display: flex;
|
|
2625
|
+
min-width: 0;
|
|
2626
|
+
align-items: baseline;
|
|
2627
|
+
justify-content: space-between;
|
|
2628
|
+
gap: 14px;
|
|
2629
|
+
margin: 0 0 18px;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
.pulse-summary strong { min-width: 0; overflow-wrap: anywhere; font-size: 17px; }
|
|
2633
|
+
.pulse-summary span { flex: 0 1 auto; color: #4f708f; font-size: 11px; font-weight: 800; text-align: right; }
|
|
2634
|
+
|
|
2635
|
+
.listening-pulse-grid {
|
|
2636
|
+
display: grid;
|
|
2637
|
+
grid-template-columns: 46px repeat(12, minmax(0, 1fr));
|
|
2638
|
+
gap: 6px;
|
|
2639
|
+
min-width: 0;
|
|
2640
|
+
align-items: center;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
.pulse-corner,
|
|
2644
|
+
.pulse-month-label,
|
|
2645
|
+
.pulse-year {
|
|
2646
|
+
color: var(--muted);
|
|
2647
|
+
font: 800 9px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2648
|
+
letter-spacing: 0.04em;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
.pulse-corner { text-align: left; }
|
|
2652
|
+
.pulse-month-label { text-align: center; }
|
|
2653
|
+
.pulse-year { padding-right: 4px; text-align: left; }
|
|
2654
|
+
|
|
2655
|
+
.pulse-cell {
|
|
2656
|
+
display: block;
|
|
2657
|
+
min-width: 0;
|
|
2658
|
+
aspect-ratio: 1;
|
|
2659
|
+
border: 1px solid rgba(79, 112, 143, 0.12);
|
|
2660
|
+
border-radius: 5px;
|
|
2661
|
+
background: rgba(111, 140, 168, 0.08);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
.pulse-cell.level-1 { background: #d7e3ed; }
|
|
2665
|
+
.pulse-cell.level-2 { background: #aec4d6; }
|
|
2666
|
+
.pulse-cell.level-3 { background: #829fba; }
|
|
2667
|
+
.pulse-cell.level-4 { background: #4f708f; }
|
|
2668
|
+
.pulse-cell.is-outside { border-color: transparent; background: transparent; }
|
|
2669
|
+
|
|
2670
|
+
.pulse-legend {
|
|
2671
|
+
display: flex;
|
|
2672
|
+
min-width: 0;
|
|
2673
|
+
align-items: center;
|
|
2674
|
+
justify-content: flex-end;
|
|
2675
|
+
gap: 5px;
|
|
2676
|
+
margin-top: 14px;
|
|
2677
|
+
color: var(--muted);
|
|
2678
|
+
font-size: 9px;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
.pulse-legend .pulse-cell { width: 12px; min-width: 12px; }
|
|
2682
|
+
.pulse-boundary { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
|
2683
|
+
|
|
2684
|
+
.listening-seasons-panel {
|
|
2685
|
+
margin-bottom: 18px;
|
|
2686
|
+
border-color: #b9a98f;
|
|
2687
|
+
background:
|
|
2688
|
+
radial-gradient(circle at 0% 100%, rgba(111, 140, 168, 0.12), transparent 30rem),
|
|
2689
|
+
linear-gradient(145deg, #fbf8f2 0%, #eee5d7 100%);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
.listening-seasons-heading {
|
|
2693
|
+
display: flex;
|
|
2694
|
+
min-width: 0;
|
|
2695
|
+
align-items: end;
|
|
2696
|
+
justify-content: space-between;
|
|
2697
|
+
gap: 18px;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
.listening-seasons-heading > div { min-width: 0; }
|
|
2701
|
+
.listening-seasons-heading h3 { margin-bottom: 0; }
|
|
2702
|
+
.listening-seasons-heading > strong {
|
|
2703
|
+
flex: 0 0 auto;
|
|
2704
|
+
color: #4f708f;
|
|
2705
|
+
font: 800 10px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2706
|
+
letter-spacing: 0.09em;
|
|
2707
|
+
text-transform: uppercase;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
.listening-seasons-panel .panel-intro { margin: 12px 0 24px; }
|
|
2711
|
+
|
|
2712
|
+
.listening-seasons-grid {
|
|
2713
|
+
display: grid;
|
|
2714
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2715
|
+
gap: 10px;
|
|
2716
|
+
min-width: 0;
|
|
2717
|
+
margin-top: 18px;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
.listening-season-card {
|
|
2721
|
+
position: relative;
|
|
2722
|
+
min-width: 0;
|
|
2723
|
+
overflow: hidden;
|
|
2724
|
+
padding: 17px;
|
|
2725
|
+
border: 1px solid rgba(84, 72, 56, 0.2);
|
|
2726
|
+
border-radius: 15px;
|
|
2727
|
+
background: rgba(255, 252, 246, 0.72);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
.listening-season-card::before {
|
|
2731
|
+
position: absolute;
|
|
2732
|
+
inset: 0 auto 0 0;
|
|
2733
|
+
width: 3px;
|
|
2734
|
+
background: #6f8ca8;
|
|
2735
|
+
content: "";
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
.listening-season-card.quarter-2::before { background: #8aa28d; }
|
|
2739
|
+
.listening-season-card.quarter-3::before { background: #c39968; }
|
|
2740
|
+
.listening-season-card.quarter-4::before { background: #866f89; }
|
|
2741
|
+
|
|
2742
|
+
.listening-season-card header,
|
|
2743
|
+
.season-total,
|
|
2744
|
+
.season-mix-label {
|
|
2745
|
+
display: flex;
|
|
2746
|
+
min-width: 0;
|
|
2747
|
+
align-items: baseline;
|
|
2748
|
+
justify-content: space-between;
|
|
2749
|
+
gap: 10px;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
.listening-season-card header strong {
|
|
2753
|
+
font: 760 11px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2754
|
+
letter-spacing: 0.04em;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
.listening-season-card header span,
|
|
2758
|
+
.listening-season-card > small {
|
|
2759
|
+
color: var(--muted);
|
|
2760
|
+
font-size: 8px;
|
|
2761
|
+
line-height: 1.45;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
.season-total { margin-top: 16px; }
|
|
2765
|
+
.season-total strong {
|
|
2766
|
+
min-width: 0;
|
|
2767
|
+
overflow-wrap: anywhere;
|
|
2768
|
+
font: 650 24px Iowan Old Style, Baskerville, serif;
|
|
2769
|
+
letter-spacing: -0.035em;
|
|
2770
|
+
}
|
|
2771
|
+
.season-total span {
|
|
2772
|
+
flex: 0 1 auto;
|
|
2773
|
+
color: var(--muted);
|
|
2774
|
+
font-size: 8px;
|
|
2775
|
+
text-align: right;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
.season-mix {
|
|
2779
|
+
height: 4px;
|
|
2780
|
+
margin-top: 13px;
|
|
2781
|
+
overflow: hidden;
|
|
2782
|
+
border-radius: 99px;
|
|
2783
|
+
background: #c4b8a7;
|
|
2784
|
+
}
|
|
2785
|
+
.season-mix span { display: block; height: 100%; background: #6f8ca8; }
|
|
2786
|
+
.season-mix-label { margin: 5px 0 0; color: var(--muted); font-size: 7px; }
|
|
2787
|
+
|
|
2788
|
+
.season-anchors { display: grid; gap: 9px; margin: 15px 0 13px; }
|
|
2789
|
+
.season-anchors div { min-width: 0; padding-top: 9px; border-top: 1px solid rgba(84, 72, 56, 0.14); }
|
|
2790
|
+
.season-anchors dt {
|
|
2791
|
+
color: var(--muted);
|
|
2792
|
+
font-size: 7px;
|
|
2793
|
+
font-weight: 800;
|
|
2794
|
+
letter-spacing: 0.08em;
|
|
2795
|
+
text-transform: uppercase;
|
|
2796
|
+
}
|
|
2797
|
+
.season-anchors dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 720; }
|
|
2798
|
+
.season-anchors dd span { display: block; margin-top: 2px; color: var(--muted); font-size: 8px; font-weight: 500; }
|
|
2799
|
+
|
|
2800
|
+
.listening-season-card.is-empty { min-height: 180px; background: rgba(255, 252, 246, 0.38); }
|
|
2801
|
+
.listening-season-card.is-empty::before { opacity: 0.35; }
|
|
2802
|
+
.season-empty-mark { height: 1px; margin: 37px 0 14px; background: repeating-linear-gradient(90deg, #bfb3a4 0 4px, transparent 4px 10px); }
|
|
2803
|
+
.listening-season-card.is-empty p { margin: 0 0 24px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
2804
|
+
.listening-season-card.is-empty > small { position: absolute; right: 17px; bottom: 17px; left: 17px; }
|
|
2805
|
+
.listening-seasons-boundary { margin: 14px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
2806
|
+
|
|
2807
|
+
.history-arc { margin: 0; padding: 0; list-style: none; }
|
|
2808
|
+
.history-arc li {
|
|
2809
|
+
display: grid;
|
|
2810
|
+
grid-template-columns: 90px minmax(0, 1fr) minmax(170px, 0.42fr);
|
|
2811
|
+
gap: 24px;
|
|
2812
|
+
align-items: center;
|
|
2813
|
+
padding: 22px 0;
|
|
2814
|
+
border-top: 1px solid var(--line);
|
|
2815
|
+
}
|
|
2816
|
+
.history-arc li:first-child { padding-top: 0; border-top: 0; }
|
|
2817
|
+
.arc-year strong { display: block; font: 650 34px Iowan Old Style, Baskerville, serif; letter-spacing: -0.04em; }
|
|
2818
|
+
.arc-year span, .arc-heading span, .arc-artist span {
|
|
2819
|
+
display: block;
|
|
2820
|
+
margin-top: 4px;
|
|
2821
|
+
color: var(--muted);
|
|
2822
|
+
font-size: 9px;
|
|
2823
|
+
font-weight: 800;
|
|
2824
|
+
letter-spacing: 0.1em;
|
|
2825
|
+
text-transform: uppercase;
|
|
2826
|
+
}
|
|
2827
|
+
.arc-body, .arc-artist { min-width: 0; }
|
|
2828
|
+
.arc-heading { display: flex; gap: 10px; align-items: baseline; }
|
|
2829
|
+
.arc-heading strong { font-size: 16px; }
|
|
2830
|
+
.arc-heading span { margin-top: 0; }
|
|
2831
|
+
.arc-body .bar { height: 7px; margin: 10px 0 8px; }
|
|
2832
|
+
.arc-body small, .arc-artist small { color: var(--muted); }
|
|
2833
|
+
.arc-artist { padding-left: 20px; border-left: 1px solid var(--line); }
|
|
2834
|
+
.arc-artist span { margin: 0 0 6px; }
|
|
2835
|
+
.arc-artist strong { display: block; overflow-wrap: anywhere; }
|
|
2836
|
+
.arc-artist small { display: block; margin-top: 5px; }
|
|
2837
|
+
|
|
2838
|
+
.track-list li, .provider-list li {
|
|
2839
|
+
display: grid;
|
|
2840
|
+
grid-template-columns: 35px minmax(0, 1fr);
|
|
2841
|
+
gap: 13px;
|
|
2842
|
+
padding: 17px 0;
|
|
2843
|
+
border-top: 1px solid var(--line);
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
.track-list li:first-child, .provider-list li:first-child { padding-top: 0; border-top: 0; }
|
|
2847
|
+
.track-index, .provider-list > li > span { color: var(--muted); font: 750 10px ui-monospace, monospace; }
|
|
2848
|
+
.track-list strong, .provider-list strong { display: block; overflow-wrap: anywhere; }
|
|
2849
|
+
.track-list p, .provider-list p { margin: 4px 0; color: var(--muted); font-size: 13px; }
|
|
2850
|
+
.track-list small, .provider-list small { color: var(--muted); }
|
|
2851
|
+
|
|
2852
|
+
.facets-grid {
|
|
2853
|
+
display: grid;
|
|
2854
|
+
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
|
2855
|
+
gap: 18px;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
.signal-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
2859
|
+
.signal-list li { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: #fbf8f2; }
|
|
2860
|
+
.signal-list strong { display: block; overflow-wrap: anywhere; }
|
|
2861
|
+
.signal-list span { display: block; margin-top: 7px; color: var(--muted); font-size: 11px; }
|
|
2862
|
+
|
|
2863
|
+
.compact-list { margin: 0; padding: 0; list-style: none; }
|
|
2864
|
+
.compact-list li { padding: 12px 0; border-top: 1px solid var(--line); }
|
|
2865
|
+
.compact-list li:first-child { padding-top: 0; border-top: 0; }
|
|
2866
|
+
.compact-list strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
|
|
2867
|
+
.compact-list span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
|
|
2868
|
+
|
|
2869
|
+
.facet-block + .facet-block { margin-top: 26px; }
|
|
2870
|
+
.facet-block h3 { margin: 0 0 13px; font-size: 14px; }
|
|
2871
|
+
.tags { display: flex; flex-wrap: wrap; gap: 7px; }
|
|
2872
|
+
.tags li { max-width: 100%; padding: 8px 11px; overflow-wrap: anywhere; border: 1px solid var(--line-strong); border-radius: 999px; background: #fbf8f2; font-size: 12px; }
|
|
2873
|
+
|
|
2874
|
+
.behavior-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
2875
|
+
.behavior-card { min-width: 0; padding: 20px; border: 1px solid #44433e; border-radius: 17px; background: var(--night-soft); }
|
|
2876
|
+
.behavior-card > div { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
|
|
2877
|
+
.behavior-card > div strong { font: 650 clamp(28px, 3.5vw, 42px) Iowan Old Style, Baskerville, serif; }
|
|
2878
|
+
.behavior-card > div span { color: #a9bfd3; font-size: 12px; font-weight: 800; }
|
|
2879
|
+
.behavior-card h3 { margin: 20px 0 8px; font-size: 14px; }
|
|
2880
|
+
.behavior-card p { margin: 0; color: #aaa49c; font-size: 11px; line-height: 1.45; }
|
|
2881
|
+
.behavior-card small { display: block; margin-top: 12px; color: #77756f; font-size: 9px; line-height: 1.4; }
|
|
2882
|
+
.context-note { margin: 16px 0 0; color: #aaa49c; font-size: 12px; }
|
|
2883
|
+
|
|
2884
|
+
.provider-intro {
|
|
2885
|
+
margin: 0 0 25px;
|
|
2886
|
+
padding: 16px 18px;
|
|
2887
|
+
border-left: 4px solid var(--accent);
|
|
2888
|
+
color: var(--muted);
|
|
2889
|
+
background: var(--accent-soft);
|
|
2890
|
+
line-height: 1.5;
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
.snapshot-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 18px; }
|
|
2894
|
+
.snapshot-cards article { min-width: 0; padding: 17px; border: 1px solid var(--line); border-radius: 14px; background: #fbf8f2; }
|
|
2895
|
+
.snapshot-cards span { display: block; margin-bottom: 14px; color: var(--muted); font-size: 9px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
|
|
2896
|
+
.snapshot-cards strong { display: block; overflow-wrap: anywhere; }
|
|
2897
|
+
.snapshot-cards p { margin: 6px 0 0; color: var(--muted); font-size: 11px; }
|
|
2898
|
+
|
|
2899
|
+
.limitations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; counter-reset: boundary; }
|
|
2900
|
+
.limitations p { position: relative; min-width: 0; margin: 0; padding: 18px 18px 18px 52px; border: 1px solid var(--line); border-radius: 14px; color: var(--muted); line-height: 1.5; background: #fbf8f2; counter-increment: boundary; }
|
|
2901
|
+
.limitations p::before { position: absolute; left: 17px; top: 18px; color: var(--ink); font: 800 10px ui-monospace, monospace; content: counter(boundary, decimal-leading-zero); }
|
|
2902
|
+
|
|
2903
|
+
.empty { margin: 0; color: var(--muted); font-style: italic; }
|
|
2904
|
+
|
|
2905
|
+
footer {
|
|
2906
|
+
display: flex;
|
|
2907
|
+
justify-content: space-between;
|
|
2908
|
+
gap: 20px;
|
|
2909
|
+
margin-top: 24px;
|
|
2910
|
+
padding: 20px 4px;
|
|
2911
|
+
color: var(--muted);
|
|
2912
|
+
font-size: 11px;
|
|
2913
|
+
line-height: 1.5;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
footer span:last-child { text-align: right; }
|
|
2917
|
+
|
|
2918
|
+
@media (max-width: 900px) {
|
|
2919
|
+
.overview, .behavior-grid, .snapshot-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
2920
|
+
.listening-seasons-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
2921
|
+
.three-column { grid-template-columns: 1fr; }
|
|
2922
|
+
.section-heading { grid-template-columns: 1fr; gap: 15px; }
|
|
2923
|
+
.facets-grid { grid-template-columns: 1fr; }
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
@media (max-width: 660px) {
|
|
2927
|
+
.shell { width: min(100% - 20px, 1220px); padding-top: 10px; }
|
|
2928
|
+
.hero { min-height: 0; padding: 30px 22px; border-radius: 20px; }
|
|
2929
|
+
.hero::before { width: 540px; right: -350px; top: -90px; opacity: 0.62; }
|
|
2930
|
+
.hero::after { right: 105px; top: 120px; }
|
|
2931
|
+
.eyebrow { margin-bottom: 30px; font-size: 9px; }
|
|
2932
|
+
h1 { font-size: clamp(49px, 16vw, 72px); }
|
|
2933
|
+
.hero-copy { margin-top: 26px; font-size: 16px; }
|
|
2934
|
+
.hero-meta { grid-template-columns: 1fr; margin-top: 38px; }
|
|
2935
|
+
.hero-meta div + div { border-top: 1px solid rgba(245, 241, 233, 0.18); border-left: 0; }
|
|
2936
|
+
.privacy { padding: 18px; }
|
|
2937
|
+
.overview, .two-column, .behavior-grid, .snapshot-cards, .signal-list, .limitations { grid-template-columns: 1fr; }
|
|
2938
|
+
.identity-coverage { grid-template-columns: 1fr; gap: 7px; }
|
|
2939
|
+
.metric-card { min-height: 150px; }
|
|
2940
|
+
.section-nav { grid-template-columns: 1fr; gap: 11px; padding: 16px; }
|
|
2941
|
+
.section-nav a { min-height: 40px; }
|
|
2942
|
+
.section { padding: 26px 20px; border-radius: 19px; }
|
|
2943
|
+
.panel { padding: 20px; }
|
|
2944
|
+
.time-machine-selection { grid-template-columns: 1fr; gap: 7px; }
|
|
2945
|
+
.relationship-head, .transition-head { align-items: start; flex-direction: column; gap: 5px; }
|
|
2946
|
+
.relationship-head span, .transition-head span { flex: 1 1 auto; }
|
|
2947
|
+
.session-lede { align-items: start; flex-direction: column; gap: 5px; }
|
|
2948
|
+
.session-lede span { text-align: left; }
|
|
2949
|
+
.session-stats { grid-template-columns: 1fr; }
|
|
2950
|
+
.session-mix li > div:first-child { align-items: start; flex-direction: column; gap: 4px; }
|
|
2951
|
+
.session-mix li > div:first-child span { flex: 1 1 auto; }
|
|
2952
|
+
.rank-heading { align-items: start; flex-direction: column; gap: 4px; }
|
|
2953
|
+
.pulse-summary { align-items: start; flex-direction: column; gap: 5px; }
|
|
2954
|
+
.pulse-summary span { text-align: left; }
|
|
2955
|
+
.listening-pulse-grid { grid-template-columns: 35px repeat(12, minmax(0, 1fr)); gap: 3px; }
|
|
2956
|
+
.pulse-corner, .pulse-month-label, .pulse-year { font-size: 7px; }
|
|
2957
|
+
.pulse-cell { border-radius: 3px; }
|
|
2958
|
+
.listening-seasons-heading { align-items: start; flex-direction: column; gap: 7px; }
|
|
2959
|
+
.listening-seasons-grid { grid-template-columns: 1fr; }
|
|
2960
|
+
.season-total span { max-width: 45%; }
|
|
2961
|
+
.history-arc li { grid-template-columns: 70px minmax(0, 1fr); gap: 14px; }
|
|
2962
|
+
.arc-artist { grid-column: 2; padding: 12px 0 0; border-top: 1px solid var(--line); border-left: 0; }
|
|
2963
|
+
footer { flex-direction: column; }
|
|
2964
|
+
footer span:last-child { text-align: left; }
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
@media print {
|
|
2968
|
+
:root { background: white; }
|
|
2969
|
+
body { background: white; }
|
|
2970
|
+
.shell { width: 100%; padding: 0; }
|
|
2971
|
+
.hero, .section, .metric-card { break-inside: avoid; box-shadow: none; }
|
|
2972
|
+
.hero { border-radius: 0; }
|
|
2973
|
+
.section-nav { display: none; }
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2977
|
+
html { scroll-behavior: auto; }
|
|
2978
|
+
}
|
|
2979
|
+
</style>
|
|
2980
|
+
</head>
|
|
2981
|
+
<body>
|
|
2982
|
+
<main class="shell">
|
|
2983
|
+
<header class="hero">
|
|
2984
|
+
<p class="eyebrow">${syntheticDemo ? "Moondog listening report, fictional demo" : "Moondog listening report, private"}</p>
|
|
2985
|
+
<h1>${escapeHtml(spanLabel(view.timeline.days))}, seen clearly.</h1>
|
|
2986
|
+
<p class="hero-copy">${escapeHtml(heroCopy)}</p>
|
|
2987
|
+
<div class="hero-meta" aria-label="Tasteprint snapshot metadata">
|
|
2988
|
+
<div><span>Your history</span><strong>${escapeHtml(rangeLabel)}</strong></div>
|
|
2989
|
+
<div><span>Made</span><strong>${escapeHtml(generatedLabel)}</strong></div>
|
|
2990
|
+
<div><span>Version</span><strong>${escapeHtml(view.profile_version)}</strong></div>
|
|
2991
|
+
</div>
|
|
2992
|
+
</header>
|
|
2993
|
+
|
|
2994
|
+
<aside class="privacy" aria-label="${syntheticDemo ? "Synthetic demo boundary" : "Privacy boundary"}">
|
|
2995
|
+
<span class="privacy-mark" aria-hidden="true">L</span>
|
|
2996
|
+
<div><strong>${syntheticDemo ? "A fictional demo" : "Private, made on this machine"}</strong><p>${syntheticDemo ? "Every artist, track, and number on this page is made up. The page loads nothing from the internet." : "This page is about you. It loads nothing from the internet. Share it only if you are happy for others to see the artists, tracks, and numbers on it."}</p></div>
|
|
2997
|
+
</aside>
|
|
2998
|
+
|
|
2999
|
+
<section class="overview" aria-label="Listening coverage">
|
|
3000
|
+
${metricCard(number(coverage.effective_listening_events), "plays", `${number(coverage.profiled_listening_events)} counted in rankings`)}
|
|
3001
|
+
${metricCard(number(coverage.listening_hours), "listening hours", rangeLabel)}
|
|
3002
|
+
${metricCard(number(coverage.listening_tracks), "tracks", resolvedShare === null ? "matching unknown" : `${number(resolvedShare, 1)}% matched to a known song`)}
|
|
3003
|
+
${metricCard(number(coverage.spotify_playlist_memberships), "songs on playlists", `${number(coverage.spotify_saved_tracks)} saved songs`)}
|
|
3004
|
+
</section>
|
|
3005
|
+
|
|
3006
|
+
${crossFormatIdentityCoverage(coverage)}
|
|
3007
|
+
|
|
3008
|
+
${sectionNavigator}
|
|
3009
|
+
|
|
3010
|
+
<section class="section dark" id="taste-shape">
|
|
3011
|
+
${syntheticDemo ? '<p class="demo-chip">Fictional public profile</p>' : ""}
|
|
3012
|
+
<div class="section-heading"><h2>Shine On</h2><p>The artists who stayed with you across the years, and the ones you have played in the last ${escapeHtml(number(recentWindow))} days. Bars compare within each list only.</p></div>
|
|
3013
|
+
<div class="two-column">
|
|
3014
|
+
<article class="panel"><span class="panel-label">All of your history</span><h3>Across the years</h3>${artistRows(view.behavior.enduring_artists)}</article>
|
|
3015
|
+
<article class="panel"><span class="panel-label">Last ${escapeHtml(number(recentWindow))} days</span><h3>Lately</h3>${artistRows(view.behavior.recent_artists, { recent: true })}</article>
|
|
3016
|
+
</div>
|
|
3017
|
+
</section>
|
|
3018
|
+
|
|
3019
|
+
<section class="section" id="listening-arc">
|
|
3020
|
+
<div class="section-heading"><h2>Year by year</h2><p>Each row is one calendar year, in UTC. A song counts as new the first time it shows up in your history, which is not always when you found it.</p></div>
|
|
3021
|
+
${view.behavior.monthly_activity ? `<article class="panel listening-pulse-panel" id="listening-pulse"><span class="panel-label">Every month in your history</span><h3>Listening Pulse</h3><p class="panel-intro">Each cell is a month. A blank month means no history was kept for it, not that you stopped listening.</p>${monthlyActivityGrid(view.behavior.monthly_activity)}</article>` : ""}
|
|
3022
|
+
${listeningSeasonsPanel(view.behavior.listening_seasons)}
|
|
3023
|
+
${historyArcRows(view.behavior.history_arc)}
|
|
3024
|
+
${view.behavior.time_capsule_tracks.length > 0 ? `<article class="panel time-capsule-panel" id="time-machine"><span class="panel-label">${escapeHtml(number(view.behavior.time_capsule_tracks.length))} years, one song each</span><h3>Time</h3><p class="panel-intro">Your Listening Time Machine: for each year, a song you played most that year, never one you asked to keep out. It is a route through your history, not a claim that the song defined the year or that you still love it.</p>${timeMachineSelectionNote(view)}${trackRows(view.behavior.time_capsule_tracks)}</article>` : ""}
|
|
3025
|
+
</section>
|
|
3026
|
+
|
|
3027
|
+
${view.behavior.artist_relationships.length > 0 || view.behavior.year_transitions.length > 0 ? `<section class="section" id="continuity">
|
|
3028
|
+
<div class="section-heading"><h2>What stayed. What changed.</h2><p>Which artists kept coming back across the years, and how much your top artists changed from one year to the next. Neither is a verdict on who you are.</p></div>
|
|
3029
|
+
<div class="two-column continuity-grid">
|
|
3030
|
+
<article class="panel"><span class="panel-label">Still there in your latest year</span><h3>Artists across the years</h3><p class="panel-intro">Each artist shows up in at least ${escapeHtml(number(context.relationship_minimum_years ?? 2))} years of your history, including the latest one.</p>${artistRelationshipRows(view.behavior.artist_relationships)}</article>
|
|
3031
|
+
<article class="panel"><span class="panel-label">Top artists by listening time</span><h3>Year to year</h3><p class="panel-intro">How many of each year's top ${escapeHtml(number(context.continuity_artist_limit ?? 10))} artists were also near the top the year before.</p>${yearTransitionRows(view.behavior.year_transitions, context.continuity_artist_limit ?? 10)}</article>
|
|
3032
|
+
</div>
|
|
3033
|
+
</section>` : ""}
|
|
3034
|
+
|
|
3035
|
+
${hasListeningPatterns ? `<section class="section pattern-section" id="listening-patterns">
|
|
3036
|
+
${syntheticDemo ? '<p class="pattern-demo-chip">Fictional archive preview</p>' : ""}
|
|
3037
|
+
<div class="section-heading"><h2>Inside a listening stretch</h2><p>Spotify's extended history shows how plays cluster, which songs you played again right away, and which records you went deep on. These are patterns, not a read on your mood, routine, or taste.</p></div>
|
|
3038
|
+
<div class="two-column pattern-grid">
|
|
3039
|
+
${view.behavior.session_summary ? `<article class="panel session-panel"><span class="panel-label">${escapeHtml(number(view.behavior.session_summary.gap_minutes))}-minute breaks</span><h3>Listening stretches</h3><p class="panel-intro">Plays are grouped together until a longer break starts a new stretch.</p>${sessionShape(view.behavior.session_summary)}</article>` : ""}
|
|
3040
|
+
${view.behavior.back_to_back_tracks.length > 0 ? `<article class="panel" id="back-to-back"><span class="panel-label">${escapeHtml(number(context.back_to_back_minimum_consecutive_plays ?? 2))} or more in a row</span><h3>Echoes</h3><p class="panel-intro">Songs you played back to back. Each play lasted at least ${escapeHtml(number(context.back_to_back_minimum_played_seconds ?? 30))} seconds, was not skipped, and started within ${escapeHtml(number(context.back_to_back_maximum_gap_minutes ?? 30))} minutes of the last. It could be love, or repeat mode, or falling asleep.</p>${trackRows(view.behavior.back_to_back_tracks)}</article>` : ""}
|
|
3041
|
+
${view.behavior.release_depth.length > 0 ? `<article class="panel"><span class="panel-label">${escapeHtml(number(context.release_minimum_distinct_tracks ?? 3))} or more tracks</span><h3>Records you went deep on</h3><p class="panel-intro">Records are matched by title and artist, so albums with the same name by different artists stay apart.</p>${releaseDepthRows(view.behavior.release_depth)}</article>` : ""}
|
|
3042
|
+
</div>
|
|
3043
|
+
</section>` : ""}
|
|
3044
|
+
|
|
3045
|
+
<section class="section" id="tracks-that-stay">
|
|
3046
|
+
<div class="section-heading"><h2>Tracks that stay, go quiet, and come back</h2><p>Playing something a lot shows you know it well. It does not prove you love it, now or forever.</p></div>
|
|
3047
|
+
${view.behavior.rediscovery_tracks.length > 0 ? `<article class="panel rediscovery-panel"><span class="panel-label">Quiet for ${escapeHtml(number(rediscoveryQuietDays))}+ days</span><h3>Wish You Were Here</h3><p class="panel-intro">Worth another listen: songs you used to play a lot that have gone quiet, leaving out anything you asked to keep out. Quiet is measured up to the last play in your history, not today.</p>${trackRows(view.behavior.rediscovery_tracks)}</article>` : ""}
|
|
3048
|
+
${view.behavior.historical_return_tracks.length > 0 ? `<article class="panel rediscovery-panel"><span class="panel-label">Back after ${escapeHtml(number(context.historical_return_minimum_gap_days ?? 180))}+ days away</span><h3>Coming Back to Life</h3><p class="panel-intro">Music that came back: songs that found their way back after long gaps. That is a pattern, not proof you missed them or left them on purpose.</p>${trackRows(view.behavior.historical_return_tracks)}</article>` : ""}
|
|
3049
|
+
<div class="two-column">
|
|
3050
|
+
<article class="panel"><span class="panel-label">All of your history</span><h3>Most played</h3>${trackRows(view.behavior.repeat_tracks)}</article>
|
|
3051
|
+
<article class="panel"><span class="panel-label">Your latest plays</span><h3>Recently</h3>${trackRows(view.behavior.recent_tracks)}</article>
|
|
3052
|
+
</div>
|
|
3053
|
+
</section>
|
|
3054
|
+
|
|
3055
|
+
${listenerCorrectionCount > 0 ? `<section class="section" id="listener-corrections">
|
|
3056
|
+
<div class="section-heading"><h2>What you told me</h2><p>Your own choices. They count for more than play counts, you can undo them any time, and they never rewrite your history.</p></div>
|
|
3057
|
+
<div class="two-column">
|
|
3058
|
+
<article class="panel"><span class="panel-label">Your choice</span><h3>You like</h3>${correctionSignalList(view.deliberate.listener_preferences, "like")}</article>
|
|
3059
|
+
<article class="panel"><span class="panel-label">Your choice</span><h3>Keep out</h3>${correctionSignalList(view.deliberate.listener_avoids, "avoid")}</article>
|
|
3060
|
+
</div>
|
|
3061
|
+
</section>` : ""}
|
|
3062
|
+
|
|
3063
|
+
${profileEvidenceSection}
|
|
3064
|
+
|
|
3065
|
+
<section class="section dark" id="playback-flow">
|
|
3066
|
+
<div class="section-heading"><h2>How you listen</h2><p>How songs started, ended, and played. These overlap, and none of them says what you like.</p></div>
|
|
3067
|
+
<div class="behavior-grid">
|
|
3068
|
+
${behaviorCard(context.direct_selection_starts, context.start_reason_events, "Picked yourself", "Songs you started directly. A choice in the moment, not a measure of how much you cared.", "plays with a start reason")}
|
|
3069
|
+
${behaviorCard(context.trackdone_starts, context.start_reason_events, "Followed on", "Started because the song before ended. That is flow, not indifference.", "plays with a start reason")}
|
|
3070
|
+
${behaviorCard(context.trackdone_endings, context.end_reason_events, "Played to the end", "Spotify marked them finished, which is not quite the same as heard all the way through.", "plays with an end reason")}
|
|
3071
|
+
${behaviorCard(context.explicit_skips, context.skip_state_events, "Skipped", "A skip is a moment, not a verdict.", "plays with skip data")}
|
|
3072
|
+
${behaviorCard(context.shuffle_events, context.shuffle_state_events, "On shuffle", "Shuffle explains the order, not your taste.", "plays with shuffle data")}
|
|
3073
|
+
${behaviorCard(context.offline_events, context.offline_state_events, "Offline", "Played without a connection. Nothing more than that.", "plays with offline data")}
|
|
3074
|
+
</div>
|
|
3075
|
+
<p class="context-note">Percentages only count plays where Spotify recorded that detail. ${escapeHtml(number(context.incognito_events_excluded))} private-session plays were left out of the rankings.</p>
|
|
3076
|
+
</section>
|
|
3077
|
+
|
|
3078
|
+
${providerSection}
|
|
3079
|
+
|
|
3080
|
+
<section class="section" id="interpretation-boundaries">
|
|
3081
|
+
<div class="section-heading"><h2>The dark side of the moon</h2><p>What this reading cannot see. Moondog keeps the doubt in view, so a nice summary never hardens into a false picture of you.</p></div>
|
|
3082
|
+
<div class="limitations">${view.limitations.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>
|
|
3083
|
+
</section>
|
|
3084
|
+
|
|
3085
|
+
<footer><span>Moondog listening report / ${syntheticDemo ? "fictional demo" : "private, made on this machine"} / works offline</span><span>Generated ${escapeHtml(view.generated_at)}</span></footer>
|
|
3086
|
+
</main>
|
|
3087
|
+
</body>
|
|
3088
|
+
</html>
|
|
3089
|
+
`;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
export function renderTasteprintCardHtml(profile, options = {}) {
|
|
3093
|
+
const view = createTasteprintView(profile, options);
|
|
3094
|
+
const syntheticDemo = view.synthetic_demo === true;
|
|
3095
|
+
const coverage = view.coverage;
|
|
3096
|
+
const recentWindow = view.behavior.context.recent_window_days ?? 90;
|
|
3097
|
+
const enduringArtists = view.behavior.enduring_artists.slice(0, 3);
|
|
3098
|
+
const recentArtists = view.behavior.recent_artists.slice(0, 2);
|
|
3099
|
+
const repeatTracks = view.behavior.repeat_tracks.slice(0, 2);
|
|
3100
|
+
const correctionCount =
|
|
3101
|
+
view.deliberate.listener_preferences.length +
|
|
3102
|
+
view.deliberate.listener_avoids.length;
|
|
3103
|
+
const rangeLabel = view.timeline.earliest && view.timeline.latest
|
|
3104
|
+
? `${dateLabel(view.timeline.earliest)} to ${dateLabel(view.timeline.latest)}`
|
|
3105
|
+
: "Bounded local profile range";
|
|
3106
|
+
const topArtist = enduringArtists[0]?.name;
|
|
3107
|
+
const headline = topArtist
|
|
3108
|
+
? `${topArtist}, shining on.`
|
|
3109
|
+
: "Your listening, so far.";
|
|
3110
|
+
const boundary =
|
|
3111
|
+
"Plays show attention, not love. What you played is not the same as who you are.";
|
|
3112
|
+
|
|
3113
|
+
const artistItems = (items, { recent = false } = {}) =>
|
|
3114
|
+
items.length > 0
|
|
3115
|
+
? `<ol class="artist-list">${items
|
|
3116
|
+
.map((item, index) => {
|
|
3117
|
+
const details = [
|
|
3118
|
+
listeningTime(item.listening_minutes),
|
|
3119
|
+
Number.isInteger(item.play_count)
|
|
3120
|
+
? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}`
|
|
3121
|
+
: null,
|
|
3122
|
+
Number.isInteger(item.distinct_tracks)
|
|
3123
|
+
? `${number(item.distinct_tracks)} ${item.distinct_tracks === 1 ? "track" : "tracks"}`
|
|
3124
|
+
: null,
|
|
3125
|
+
].filter(Boolean);
|
|
3126
|
+
const relationship = recent
|
|
3127
|
+
? Number.isInteger(item.long_arc_rank)
|
|
3128
|
+
? `#${item.long_arc_rank} across the years`
|
|
3129
|
+
: "new lately"
|
|
3130
|
+
: `#${index + 1} across the years`;
|
|
3131
|
+
return `<li><span class="rank">${String(index + 1).padStart(2, "0")}</span><div><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml([relationship, ...details].join(" / "))}</small></div></li>`;
|
|
3132
|
+
})
|
|
3133
|
+
.join("")}</ol>`
|
|
3134
|
+
: '<p class="empty-card">Nothing here yet.</p>';
|
|
3135
|
+
|
|
3136
|
+
const trackItems = repeatTracks.length > 0
|
|
3137
|
+
? `<ol class="track-card-list">${repeatTracks
|
|
3138
|
+
.map((item, index) => {
|
|
3139
|
+
const details = [
|
|
3140
|
+
item.artist_credit,
|
|
3141
|
+
Number.isInteger(item.play_count)
|
|
3142
|
+
? `${number(item.play_count)} ${item.play_count === 1 ? "play" : "plays"}`
|
|
3143
|
+
: null,
|
|
3144
|
+
listeningTime(item.listening_minutes),
|
|
3145
|
+
].filter(Boolean);
|
|
3146
|
+
return `<li><span>${String(index + 1).padStart(2, "0")}</span><div><strong>${escapeHtml(item.label)}</strong><small>${escapeHtml(details.join(" / "))}</small></div></li>`;
|
|
3147
|
+
})
|
|
3148
|
+
.join("")}</ol>`
|
|
3149
|
+
: '<p class="empty-card">Nothing here yet.</p>';
|
|
3150
|
+
|
|
3151
|
+
return `<!doctype html>
|
|
3152
|
+
<html lang="en">
|
|
3153
|
+
<head>
|
|
3154
|
+
<meta charset="utf-8">
|
|
3155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3156
|
+
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
3157
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
|
3158
|
+
<meta name="color-scheme" content="light">
|
|
3159
|
+
<title>${syntheticDemo ? "Moondog Synthetic Tasteprint Card Demo" : "Private Moondog Tasteprint Card"}</title>
|
|
3160
|
+
<style>
|
|
3161
|
+
:root {
|
|
3162
|
+
color: #1b1b19;
|
|
3163
|
+
background: #151514;
|
|
3164
|
+
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
* { box-sizing: border-box; }
|
|
3168
|
+
|
|
3169
|
+
html { min-width: 320px; }
|
|
3170
|
+
|
|
3171
|
+
body {
|
|
3172
|
+
min-height: 100vh;
|
|
3173
|
+
margin: 0;
|
|
3174
|
+
padding: 20px;
|
|
3175
|
+
display: grid;
|
|
3176
|
+
place-items: center;
|
|
3177
|
+
background:
|
|
3178
|
+
radial-gradient(circle at 18% 12%, rgba(111, 140, 168, 0.18), transparent 28rem),
|
|
3179
|
+
#151514;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
.taste-card {
|
|
3183
|
+
width: min(1200px, 100%);
|
|
3184
|
+
min-height: min(800px, calc(100vh - 40px));
|
|
3185
|
+
display: grid;
|
|
3186
|
+
grid-template-columns: minmax(310px, 0.9fr) minmax(0, 2.1fr);
|
|
3187
|
+
overflow: hidden;
|
|
3188
|
+
border: 1px solid #52504b;
|
|
3189
|
+
border-radius: 34px;
|
|
3190
|
+
background: #eee8de;
|
|
3191
|
+
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
.identity {
|
|
3195
|
+
position: relative;
|
|
3196
|
+
isolation: isolate;
|
|
3197
|
+
min-width: 0;
|
|
3198
|
+
overflow: hidden;
|
|
3199
|
+
padding: 42px 38px 36px;
|
|
3200
|
+
display: flex;
|
|
3201
|
+
flex-direction: column;
|
|
3202
|
+
color: #f5f1e9;
|
|
3203
|
+
background: #1b1b19;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
.identity::before {
|
|
3207
|
+
position: absolute;
|
|
3208
|
+
z-index: -1;
|
|
3209
|
+
width: 500px;
|
|
3210
|
+
aspect-ratio: 1;
|
|
3211
|
+
left: -275px;
|
|
3212
|
+
top: 120px;
|
|
3213
|
+
border: 1px solid rgba(245, 241, 233, 0.24);
|
|
3214
|
+
border-radius: 50%;
|
|
3215
|
+
box-shadow:
|
|
3216
|
+
0 0 0 24px rgba(245, 241, 233, 0.035),
|
|
3217
|
+
0 0 0 62px rgba(245, 241, 233, 0.026),
|
|
3218
|
+
0 0 0 108px rgba(245, 241, 233, 0.018);
|
|
3219
|
+
content: "";
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
.identity::after {
|
|
3223
|
+
position: absolute;
|
|
3224
|
+
z-index: -1;
|
|
3225
|
+
width: 14px;
|
|
3226
|
+
height: 14px;
|
|
3227
|
+
left: 168px;
|
|
3228
|
+
top: 351px;
|
|
3229
|
+
border: 8px solid rgba(169, 191, 211, 0.22);
|
|
3230
|
+
border-radius: 50%;
|
|
3231
|
+
background: #a9bfd3;
|
|
3232
|
+
content: "";
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
.brand-row, .card-footer {
|
|
3236
|
+
display: flex;
|
|
3237
|
+
min-width: 0;
|
|
3238
|
+
justify-content: space-between;
|
|
3239
|
+
gap: 16px;
|
|
3240
|
+
align-items: center;
|
|
3241
|
+
font-size: 9px;
|
|
3242
|
+
font-weight: 800;
|
|
3243
|
+
letter-spacing: 0.16em;
|
|
3244
|
+
text-transform: uppercase;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
.brand-row span:last-child { color: #949089; }
|
|
3248
|
+
|
|
3249
|
+
.card-kicker {
|
|
3250
|
+
margin: 140px 0 18px;
|
|
3251
|
+
color: #a9bfd3;
|
|
3252
|
+
font-size: 10px;
|
|
3253
|
+
font-weight: 800;
|
|
3254
|
+
letter-spacing: 0.17em;
|
|
3255
|
+
text-transform: uppercase;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
h1, h2, .metric strong {
|
|
3259
|
+
font-family: Iowan Old Style, Baskerville, "Times New Roman", serif;
|
|
3260
|
+
font-weight: 650;
|
|
3261
|
+
letter-spacing: -0.04em;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
h1 {
|
|
3265
|
+
max-width: 320px;
|
|
3266
|
+
margin: 0;
|
|
3267
|
+
overflow-wrap: anywhere;
|
|
3268
|
+
font-size: clamp(50px, 5.3vw, 72px);
|
|
3269
|
+
line-height: 0.92;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
.span {
|
|
3273
|
+
margin: 22px 0 0;
|
|
3274
|
+
color: #bcb7af;
|
|
3275
|
+
font-size: 15px;
|
|
3276
|
+
line-height: 1.5;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
.metrics {
|
|
3280
|
+
display: grid;
|
|
3281
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
3282
|
+
gap: 1px;
|
|
3283
|
+
margin-top: auto;
|
|
3284
|
+
border: 1px solid #42413d;
|
|
3285
|
+
border-radius: 16px;
|
|
3286
|
+
background: #42413d;
|
|
3287
|
+
overflow: hidden;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
.metric {
|
|
3291
|
+
min-width: 0;
|
|
3292
|
+
padding: 15px 12px;
|
|
3293
|
+
background: #252522;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
.metric strong {
|
|
3297
|
+
display: block;
|
|
3298
|
+
overflow-wrap: anywhere;
|
|
3299
|
+
color: #f5f1e9;
|
|
3300
|
+
font-size: 21px;
|
|
3301
|
+
line-height: 1;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
.metric span {
|
|
3305
|
+
display: block;
|
|
3306
|
+
margin-top: 7px;
|
|
3307
|
+
color: #949089;
|
|
3308
|
+
font-size: 8px;
|
|
3309
|
+
font-weight: 800;
|
|
3310
|
+
letter-spacing: 0.11em;
|
|
3311
|
+
text-transform: uppercase;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
.content {
|
|
3315
|
+
min-width: 0;
|
|
3316
|
+
padding: 34px 38px 28px;
|
|
3317
|
+
display: grid;
|
|
3318
|
+
grid-template-rows: auto auto 1fr auto;
|
|
3319
|
+
gap: 18px;
|
|
3320
|
+
background:
|
|
3321
|
+
radial-gradient(circle at 100% 0%, rgba(255, 255, 255, 0.7), transparent 28rem),
|
|
3322
|
+
#eee8de;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
.content-header {
|
|
3326
|
+
display: flex;
|
|
3327
|
+
min-width: 0;
|
|
3328
|
+
justify-content: space-between;
|
|
3329
|
+
gap: 24px;
|
|
3330
|
+
align-items: end;
|
|
3331
|
+
padding-bottom: 18px;
|
|
3332
|
+
border-bottom: 1px solid #c8bdaf;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
.content-header p { margin: 0; }
|
|
3336
|
+
|
|
3337
|
+
.content-title {
|
|
3338
|
+
color: #68625a;
|
|
3339
|
+
font-size: 10px;
|
|
3340
|
+
font-weight: 800;
|
|
3341
|
+
letter-spacing: 0.14em;
|
|
3342
|
+
text-transform: uppercase;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
.content-range {
|
|
3346
|
+
max-width: 360px;
|
|
3347
|
+
overflow-wrap: anywhere;
|
|
3348
|
+
font-size: 12px;
|
|
3349
|
+
font-weight: 750;
|
|
3350
|
+
text-align: right;
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
.long-arc h2 {
|
|
3354
|
+
margin: 0 0 12px;
|
|
3355
|
+
font-size: 34px;
|
|
3356
|
+
line-height: 1;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
.artist-list, .track-card-list {
|
|
3360
|
+
margin: 0;
|
|
3361
|
+
padding: 0;
|
|
3362
|
+
list-style: none;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
.long-arc .artist-list {
|
|
3366
|
+
display: grid;
|
|
3367
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
3368
|
+
border: 1px solid #c8bdaf;
|
|
3369
|
+
border-radius: 18px;
|
|
3370
|
+
overflow: hidden;
|
|
3371
|
+
background: rgba(248, 244, 237, 0.75);
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
.long-arc .artist-list li {
|
|
3375
|
+
min-width: 0;
|
|
3376
|
+
padding: 18px;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
.long-arc .artist-list li + li { border-left: 1px solid #c8bdaf; }
|
|
3380
|
+
|
|
3381
|
+
.artist-list li, .track-card-list li {
|
|
3382
|
+
display: grid;
|
|
3383
|
+
grid-template-columns: 30px minmax(0, 1fr);
|
|
3384
|
+
gap: 10px;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
.rank, .track-card-list > li > span {
|
|
3388
|
+
color: #6f8ca8;
|
|
3389
|
+
font: 800 10px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
.artist-list strong, .track-card-list strong {
|
|
3393
|
+
display: block;
|
|
3394
|
+
overflow-wrap: anywhere;
|
|
3395
|
+
font-size: 14px;
|
|
3396
|
+
line-height: 1.25;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
.artist-list small, .track-card-list small {
|
|
3400
|
+
display: block;
|
|
3401
|
+
margin-top: 7px;
|
|
3402
|
+
overflow-wrap: anywhere;
|
|
3403
|
+
color: #68625a;
|
|
3404
|
+
font-size: 9px;
|
|
3405
|
+
line-height: 1.45;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
.signal-grid {
|
|
3409
|
+
min-height: 0;
|
|
3410
|
+
display: grid;
|
|
3411
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
3412
|
+
gap: 14px;
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
.signal-panel {
|
|
3416
|
+
min-width: 0;
|
|
3417
|
+
padding: 22px;
|
|
3418
|
+
border: 1px solid #c8bdaf;
|
|
3419
|
+
border-radius: 20px;
|
|
3420
|
+
background: #f8f4ed;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
.panel-label {
|
|
3424
|
+
display: block;
|
|
3425
|
+
margin-bottom: 8px;
|
|
3426
|
+
color: #6f8ca8;
|
|
3427
|
+
font-size: 9px;
|
|
3428
|
+
font-weight: 850;
|
|
3429
|
+
letter-spacing: 0.14em;
|
|
3430
|
+
text-transform: uppercase;
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
.signal-panel h2 {
|
|
3434
|
+
margin: 0 0 20px;
|
|
3435
|
+
font-size: 29px;
|
|
3436
|
+
line-height: 1;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
.signal-panel .artist-list li,
|
|
3440
|
+
.track-card-list li {
|
|
3441
|
+
padding: 13px 0;
|
|
3442
|
+
border-top: 1px solid #d8cec1;
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
.signal-panel .artist-list li:first-child,
|
|
3446
|
+
.track-card-list li:first-child {
|
|
3447
|
+
padding-top: 0;
|
|
3448
|
+
border-top: 0;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
.boundary {
|
|
3452
|
+
display: grid;
|
|
3453
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
3454
|
+
gap: 14px;
|
|
3455
|
+
align-items: start;
|
|
3456
|
+
padding: 15px 17px;
|
|
3457
|
+
border: 1px solid #9f9384;
|
|
3458
|
+
border-radius: 16px;
|
|
3459
|
+
background: rgba(215, 227, 237, 0.56);
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
.boundary-mark {
|
|
3463
|
+
display: grid;
|
|
3464
|
+
width: 29px;
|
|
3465
|
+
height: 29px;
|
|
3466
|
+
place-items: center;
|
|
3467
|
+
border-radius: 50%;
|
|
3468
|
+
color: #f5f1e9;
|
|
3469
|
+
background: #1b1b19;
|
|
3470
|
+
font-size: 11px;
|
|
3471
|
+
font-weight: 850;
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
.boundary strong { display: block; margin-bottom: 4px; font-size: 12px; }
|
|
3475
|
+
.boundary p { margin: 0; color: #68625a; font-size: 10px; line-height: 1.45; }
|
|
3476
|
+
|
|
3477
|
+
.direct-signal {
|
|
3478
|
+
display: inline-flex;
|
|
3479
|
+
margin-top: 8px;
|
|
3480
|
+
color: #4d6276;
|
|
3481
|
+
font-size: 9px;
|
|
3482
|
+
font-weight: 800;
|
|
3483
|
+
letter-spacing: 0.08em;
|
|
3484
|
+
text-transform: uppercase;
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
.card-footer {
|
|
3488
|
+
padding-top: 2px;
|
|
3489
|
+
color: #777169;
|
|
3490
|
+
font-size: 8px;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
.empty-card { margin: 0; color: #68625a; font-size: 11px; font-style: italic; }
|
|
3494
|
+
|
|
3495
|
+
@media (max-width: 820px) {
|
|
3496
|
+
body { padding: 10px; place-items: start center; }
|
|
3497
|
+
.taste-card { min-height: 0; grid-template-columns: 1fr; border-radius: 22px; }
|
|
3498
|
+
.identity { min-height: 560px; padding: 30px 24px; }
|
|
3499
|
+
.identity::after { display: none; }
|
|
3500
|
+
.card-kicker { margin-top: 110px; }
|
|
3501
|
+
.content { padding: 28px 22px; }
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
@media (max-width: 560px) {
|
|
3505
|
+
.content-header { align-items: start; flex-direction: column; gap: 8px; }
|
|
3506
|
+
.content-range { text-align: left; }
|
|
3507
|
+
.long-arc .artist-list, .signal-grid { grid-template-columns: 1fr; }
|
|
3508
|
+
.long-arc .artist-list li + li { border-top: 1px solid #c8bdaf; border-left: 0; }
|
|
3509
|
+
.metrics { grid-template-columns: 1fr; }
|
|
3510
|
+
.metric { display: flex; justify-content: space-between; align-items: baseline; }
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
@media print {
|
|
3514
|
+
:root, body { background: white; }
|
|
3515
|
+
body { padding: 0; }
|
|
3516
|
+
.taste-card { width: 100%; min-height: 100vh; border-radius: 0; box-shadow: none; }
|
|
3517
|
+
}
|
|
3518
|
+
</style>
|
|
3519
|
+
</head>
|
|
3520
|
+
<body>
|
|
3521
|
+
<main class="taste-card" data-artifact="moondog-tasteprint-card/1">
|
|
3522
|
+
<section class="identity" aria-labelledby="card-title">
|
|
3523
|
+
<div class="brand-row"><span>Moondog</span><span>${syntheticDemo ? "Fictional demo" : "Private recap"}</span></div>
|
|
3524
|
+
<p class="card-kicker">Tasteprint card / across the years</p>
|
|
3525
|
+
<h1 id="card-title">${escapeHtml(headline)}</h1>
|
|
3526
|
+
<p class="span">${escapeHtml(spanLabel(view.timeline.days))}, read from your own listening history.</p>
|
|
3527
|
+
<div class="metrics" aria-label="Listening coverage">
|
|
3528
|
+
<div class="metric"><strong>${escapeHtml(number(coverage.listening_hours))}</strong><span>hours</span></div>
|
|
3529
|
+
<div class="metric"><strong>${escapeHtml(number(coverage.listening_tracks))}</strong><span>tracks</span></div>
|
|
3530
|
+
<div class="metric"><strong>${escapeHtml(number(coverage.effective_listening_events))}</strong><span>plays</span></div>
|
|
3531
|
+
</div>
|
|
3532
|
+
</section>
|
|
3533
|
+
|
|
3534
|
+
<section class="content" aria-label="Tasteprint recap">
|
|
3535
|
+
<header class="content-header">
|
|
3536
|
+
<p class="content-title">${syntheticDemo ? "Fictional public profile" : "Private listening recap"}</p>
|
|
3537
|
+
<p class="content-range">${escapeHtml(rangeLabel)}</p>
|
|
3538
|
+
</header>
|
|
3539
|
+
|
|
3540
|
+
<section class="long-arc" aria-labelledby="long-arc-title">
|
|
3541
|
+
<h2 id="long-arc-title">Shine On</h2>
|
|
3542
|
+
${artistItems(enduringArtists)}
|
|
3543
|
+
</section>
|
|
3544
|
+
|
|
3545
|
+
<div class="signal-grid">
|
|
3546
|
+
<section class="signal-panel" aria-labelledby="recent-title">
|
|
3547
|
+
<span class="panel-label">Last ${escapeHtml(number(recentWindow))} days</span>
|
|
3548
|
+
<h2 id="recent-title">Lately</h2>
|
|
3549
|
+
${artistItems(recentArtists, { recent: true })}
|
|
3550
|
+
</section>
|
|
3551
|
+
<section class="signal-panel" aria-labelledby="tracks-title">
|
|
3552
|
+
<span class="panel-label">All of your history</span>
|
|
3553
|
+
<h2 id="tracks-title">Most played</h2>
|
|
3554
|
+
${trackItems}
|
|
3555
|
+
</section>
|
|
3556
|
+
</div>
|
|
3557
|
+
|
|
3558
|
+
<div>
|
|
3559
|
+
<aside class="boundary" aria-label="Interpretation and privacy boundary">
|
|
3560
|
+
<span class="boundary-mark" aria-hidden="true">L</span>
|
|
3561
|
+
<div>
|
|
3562
|
+
<strong>${syntheticDemo ? "A fictional demo" : "Read it before you share it"}</strong>
|
|
3563
|
+
<p>${syntheticDemo ? "Every artist, track, date, and number on this card is made up." : "This card is about you. Check every artist, track, date, and number on it before you share it."}</p>
|
|
3564
|
+
<p>${escapeHtml(boundary)}</p>
|
|
3565
|
+
${correctionCount > 0 ? `<span class="direct-signal">${escapeHtml(number(correctionCount))} of your own ${correctionCount === 1 ? "choice" : "choices"} applied</span>` : ""}
|
|
3566
|
+
</div>
|
|
3567
|
+
</aside>
|
|
3568
|
+
<footer class="card-footer"><span>Moondog recap card</span><span>Works offline / loads nothing from the internet</span></footer>
|
|
3569
|
+
</div>
|
|
3570
|
+
</section>
|
|
3571
|
+
</main>
|
|
3572
|
+
</body>
|
|
3573
|
+
</html>
|
|
3574
|
+
`;
|
|
3575
|
+
}
|