Skip to content

Commit 078b899

Browse files
Merge pull request #11 from enricobattocchi/claude/fix-future-events-placement-Yte3I
fix: position future events to the right of Now on the timeline
2 parents 4ff2b7a + 59c586e commit 078b899

6 files changed

Lines changed: 176 additions & 56 deletions

File tree

src/app/[locale]/[...ids]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export default async function EventPage({ params, searchParams }: PageProps) {
119119
const nowName = tCommon("now");
120120

121121
const spanT = (key: string, values?: Record<string, string | number>) => tDate(key, values);
122-
const timeline = computeTimeline(events, 2, locale, nowName, spanT);
122+
const timeline = computeTimeline(events, 2, locale, nowName, spanT, hideNow);
123123
const href = buildShareablePath(events);
124124

125125
return (

src/app/api/og/route.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,10 @@ export async function GET(request: NextRequest) {
7676

7777
const font = await getFont();
7878

79-
let timeline = allEvents.length > 0 ? computeTimeline(allEvents) : null;
80-
81-
if (hideNow && timeline && allEvents.length >= 2) {
82-
const lastMarker = timeline.markers[timeline.markers.length - 1];
83-
if (lastMarker?.event.id === "0") {
84-
timeline = {
85-
...timeline,
86-
markers: timeline.markers.slice(0, -1),
87-
segments: timeline.segments.slice(0, -1),
88-
};
89-
}
90-
}
79+
const nowLabel = messages.common?.now || "Now";
80+
let timeline = allEvents.length > 0 ? computeTimeline(allEvents, 2, "en-US", nowLabel, undefined, hideNow) : null;
9181

9282
const fallbackDescription = messages.meta?.siteDescription || "Visualize the time between historical events.";
93-
const nowLabel = messages.common?.now || "Now";
9483

9584
return new ImageResponse(
9685
(

src/components/Chooser/Chooser.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ export default function Chooser({
175175
window.history.replaceState(null, "", buildUrl(path, title, false));
176176
}, [selected, title, localePath]);
177177

178-
// Recompute client-side when format differs from default
179-
const needsClientCompute = timespanFormat !== 2;
178+
// Recompute client-side when format differs from default or hideNow changed
179+
const needsClientCompute = timespanFormat !== 2 || hideNow !== (serverHideNow || false);
180180

181181
const nowLabel = tCommon("now");
182182
const spanT = useCallback(
@@ -189,25 +189,14 @@ export default function Chooser({
189189
let h: string;
190190

191191
if (needsClientCompute && selected.length > 0) {
192-
const result = computeTimeline(selected, timespanFormat, locale, nowLabel, spanT);
192+
const result = computeTimeline(selected, timespanFormat, locale, nowLabel, spanT, hideNow);
193193
tl = { markers: result.markers, segments: result.segments };
194194
h = buildShareablePath(selected);
195195
} else {
196196
tl = serverTimeline || { markers: [], segments: [] };
197197
h = serverHref || "/";
198198
}
199199

200-
// Strip Now marker + last segment when hidden and 2+ events
201-
if (hideNow && selected.length >= 2 && tl.markers.length > 0) {
202-
const lastMarker = tl.markers[tl.markers.length - 1];
203-
if (lastMarker.event.id === "0") {
204-
tl = {
205-
markers: tl.markers.slice(0, -1),
206-
segments: tl.segments.slice(0, -1),
207-
};
208-
}
209-
}
210-
211200
return { timeline: tl, href: h };
212201
}, [needsClientCompute, selected, timespanFormat, serverTimeline, serverHref, hideNow, locale, nowLabel, spanT]);
213202

src/components/Timeline/Timeline.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function AnimatedTimeline({ markers, segments, exit = false, onRemove, onToggleD
4949
const zoomedWidth = useMemo(() => {
5050
if (!zoomed || markers.length < 2) return undefined;
5151
if (typeof window !== "undefined" && window.innerWidth <= 640) return undefined;
52-
// Normalize positions to the actual visible range (handles hidden Now marker)
52+
// Normalize positions to the actual visible range
5353
const first = markers[0].position;
5454
const last = markers[markers.length - 1].position;
5555
const range = last - first;
@@ -232,7 +232,8 @@ function AnimatedTimeline({ markers, segments, exit = false, onRemove, onToggleD
232232
});
233233
} else {
234234
// ── SUBSEQUENT: new markers slide from Now, existing FLIP ──
235-
const nowPos = markerEls[markerEls.length - 1][posProp];
235+
const nowMarkerIdx = markers.findIndex(m => m.event.id === "0");
236+
const nowPos = nowMarkerIdx >= 0 ? markerEls[nowMarkerIdx][posProp] : markerEls[markerEls.length - 1][posProp];
236237

237238
markers.forEach((marker, i) => {
238239
const el = markerEls[i];

src/lib/timeline-math.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,113 @@ describe("computeTimeline", () => {
189189
});
190190
});
191191

192+
describe("future events", () => {
193+
it("places a single future event to the right of Now", () => {
194+
const events = [makeEvent({ id: "Q1", year: 2030 })];
195+
const result = computeTimeline(events);
196+
// Now should be at position 0, future event at position 100
197+
const nowMarker = result.markers.find(m => m.event.id === "0");
198+
const eventMarker = result.markers.find(m => m.event.id === "Q1");
199+
expect(nowMarker!.position).toBe(0);
200+
expect(eventMarker!.position).toBe(100);
201+
});
202+
203+
it("positions Now chronologically between past and future events", () => {
204+
const events = [
205+
makeEvent({ id: "Q1", year: 2000 }),
206+
makeEvent({ id: "Q2", year: 2048 }),
207+
];
208+
const result = computeTimeline(events);
209+
// 2000 → 2024 → 2048, total span = 48 years
210+
const pastMarker = result.markers.find(m => m.event.id === "Q1");
211+
const nowMarker = result.markers.find(m => m.event.id === "0");
212+
const futureMarker = result.markers.find(m => m.event.id === "Q2");
213+
expect(pastMarker!.position).toBe(0);
214+
expect(nowMarker!.position).toBe(50); // 24/48 = 50%
215+
expect(futureMarker!.position).toBe(100);
216+
});
217+
218+
it("handles all events in the future", () => {
219+
const events = [
220+
makeEvent({ id: "Q1", year: 2030 }),
221+
makeEvent({ id: "Q2", year: 2040 }),
222+
];
223+
const result = computeTimeline(events);
224+
// Now (2024) → 2030 → 2040, total span = 16 years
225+
const nowMarker = result.markers.find(m => m.event.id === "0");
226+
const event1 = result.markers.find(m => m.event.id === "Q1");
227+
const event2 = result.markers.find(m => m.event.id === "Q2");
228+
expect(nowMarker!.position).toBe(0);
229+
expect(event1!.position).toBeCloseTo((6 / 16) * 100, 1); // 6/16
230+
expect(event2!.position).toBe(100);
231+
});
232+
233+
it("creates correct number of segments with mixed past/future", () => {
234+
const events = [
235+
makeEvent({ id: "Q1", year: 2000 }),
236+
makeEvent({ id: "Q2", year: 2048 }),
237+
];
238+
const result = computeTimeline(events);
239+
// 3 points (event, now, event) → 2 segments
240+
expect(result.segments).toHaveLength(2);
241+
expect(result.markers).toHaveLength(3);
242+
});
243+
244+
it("segment percentages sum to 100 with future events", () => {
245+
const events = [
246+
makeEvent({ id: "Q1", year: 2000 }),
247+
makeEvent({ id: "Q2", year: 2030 }),
248+
makeEvent({ id: "Q3", year: 2048 }),
249+
];
250+
const result = computeTimeline(events);
251+
const sum = result.segments.reduce((s, seg) => s + seg.percentage, 0);
252+
expect(sum).toBeCloseTo(100, 5);
253+
});
254+
255+
it("sorts Now among markers in chronological order", () => {
256+
const events = [
257+
makeEvent({ id: "Q1", name: "Past", year: 2000 }),
258+
makeEvent({ id: "Q2", name: "Future", year: 2030 }),
259+
];
260+
const result = computeTimeline(events);
261+
expect(result.markers[0].event.name).toBe("Past");
262+
expect(result.markers[1].event.name).toBe("Now");
263+
expect(result.markers[2].event.name).toBe("Future");
264+
});
265+
});
266+
267+
describe("hideNow", () => {
268+
it("excludes Now marker when hideNow is true and 2+ events", () => {
269+
const events = [
270+
makeEvent({ id: "Q1", year: 2000 }),
271+
makeEvent({ id: "Q2", year: 2020 }),
272+
];
273+
const result = computeTimeline(events, 2, "en-US", "Now", undefined, true);
274+
// Only event markers, no Now
275+
expect(result.markers).toHaveLength(2);
276+
expect(result.markers.every(m => m.event.id !== "0")).toBe(true);
277+
});
278+
279+
it("keeps Now when hideNow is true but only 1 event", () => {
280+
const events = [makeEvent({ id: "Q1", year: 2000 })];
281+
const result = computeTimeline(events, 2, "en-US", "Now", undefined, true);
282+
expect(result.markers.some(m => m.event.id === "0")).toBe(true);
283+
});
284+
285+
it("works with future events and hideNow", () => {
286+
const events = [
287+
makeEvent({ id: "Q1", year: 2000 }),
288+
makeEvent({ id: "Q2", year: 2048 }),
289+
];
290+
const result = computeTimeline(events, 2, "en-US", "Now", undefined, true);
291+
expect(result.markers).toHaveLength(2);
292+
expect(result.markers[0].position).toBe(0);
293+
expect(result.markers[1].position).toBe(100);
294+
expect(result.segments).toHaveLength(1);
295+
expect(result.segments[0].percentage).toBe(100);
296+
});
297+
});
298+
192299
describe("timespanFormat", () => {
193300
it("uses precise format by default (format 2)", () => {
194301
const events = [makeEvent({ id: "Q1", year: 2000, month: 6, day: 15 })];

src/lib/timeline-math.ts

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export function computeTimeline(
4646
timespanFormat: TimespanFormat = 2,
4747
locale: string = "en-US",
4848
nowName: string = "Now",
49-
spanT?: SpanTranslate
49+
spanT?: SpanTranslate,
50+
hideNow?: boolean
5051
): TimelineResult {
5152
const precision = eventPrecision(events);
5253
const yearsOnly = precision === "year";
@@ -62,48 +63,81 @@ export function computeTimeline(
6263

6364
const dates = sorted.map((e) => eventToDate(e, precision));
6465

65-
// Total span from oldest event to now
66-
const totalSpan = spanValue(dates[0], now, precision);
66+
// Find where Now belongs chronologically among the sorted events
67+
const nowTime = now.getTime();
68+
let nowIndex: number;
69+
if (hideNow && sorted.length >= 2) {
70+
nowIndex = -1; // Now excluded from timeline
71+
} else {
72+
nowIndex = dates.findIndex(d => d.getTime() > nowTime);
73+
if (nowIndex === -1) nowIndex = dates.length; // Now is after all events
74+
}
75+
76+
// Build full ordered list of all timeline points (events + Now when included)
77+
const allDates = [...dates];
78+
if (nowIndex >= 0) {
79+
allDates.splice(nowIndex, 0, now);
80+
}
6781

68-
// Build segments: between each consecutive pair of points (events + now)
69-
const allDates = [...dates, now];
82+
// Total span from first to last point on the timeline
83+
const totalSpan = spanValue(allDates[0], allDates[allDates.length - 1], precision);
84+
85+
// Build segments between each consecutive pair of points
7086
const segments: SegmentData[] = [];
71-
const total = sorted.length; // number of segments = number of events
87+
const total = allDates.length - 1; // number of segments = points - 1
7288

73-
for (let i = 0; i < sorted.length; i++) {
89+
for (let i = 0; i < total; i++) {
7490
const d1 = allDates[i];
7591
const d2 = allDates[i + 1];
7692
const span = spanValue(d1, d2, precision);
7793
const percentage = totalSpan > 0 ? (100 * span) / totalSpan : 100 / total;
7894

95+
const isFirst = i === 0;
96+
const isLast = i === total - 1;
97+
98+
let startLabel = "";
99+
if (isFirst) {
100+
startLabel = nowIndex === 0
101+
? formatNowLabel(precision, now, locale)
102+
: formatEventDate(sorted[0], locale);
103+
}
104+
105+
let endLabel = "";
106+
if (isLast) {
107+
endLabel = nowIndex >= 0 && nowIndex === dates.length
108+
? formatNowLabel(precision, now, locale)
109+
: formatEventDate(sorted[sorted.length - 1], locale);
110+
}
111+
79112
segments.push({
80-
startLabel: i === 0 ? formatEventDate(sorted[0], locale) : "",
81-
endLabel: i === sorted.length - 1 ? formatNowLabel(precision, now, locale) : "",
113+
startLabel,
114+
endLabel,
82115
spanLabel: formatSpan(d1, d2, yearsOnly, timespanFormat, monthsOnly, spanT),
83116
percentage,
84117
order: i,
85118
total,
86119
});
87120
}
88121

89-
// Build markers: one per event + "Now"
90-
const markers: MarkerData[] = sorted.map((event, i) => {
91-
const pos = totalSpan > 0
92-
? (100 * spanValue(dates[0], dates[i], precision)) / totalSpan
93-
: (100 * i) / (sorted.length);
94-
return {
95-
event,
96-
label: formatEventDate(event, locale),
97-
position: pos,
98-
};
99-
});
122+
// Build markers in chronological order with Now at its correct position (when included)
123+
const nowEvent: Event = { id: "0", name: nowName, description: null, year: currentYear(), month: null, day: null, type: "", link: null, dateProperty: null, deathYear: null, deathMonth: null, deathDay: null, useDeath: false };
124+
const markers: MarkerData[] = [];
125+
let eventIdx = 0;
100126

101-
// "Now" marker
102-
markers.push({
103-
event: { id: "0", name: nowName, description: null, year: currentYear(), month: null, day: null, type: "", link: null, dateProperty: null, deathYear: null, deathMonth: null, deathDay: null, useDeath: false },
104-
label: formatNowLabel(precision, now, locale),
105-
position: 100,
106-
});
127+
for (let i = 0; i < allDates.length; i++) {
128+
if (nowIndex >= 0 && i === nowIndex) {
129+
const pos = totalSpan > 0
130+
? (100 * spanValue(allDates[0], now, precision)) / totalSpan
131+
: (100 * nowIndex) / (allDates.length - 1 || 1);
132+
markers.push({ event: nowEvent, label: formatNowLabel(precision, now, locale), position: pos });
133+
} else {
134+
const pos = totalSpan > 0
135+
? (100 * spanValue(allDates[0], dates[eventIdx], precision)) / totalSpan
136+
: (100 * i) / (allDates.length - 1 || 1);
137+
markers.push({ event: sorted[eventIdx], label: formatEventDate(sorted[eventIdx], locale), position: pos });
138+
eventIdx++;
139+
}
140+
}
107141

108142
return { markers, segments, totalDays: totalSpan, yearsOnly, precision };
109143
}

0 commit comments

Comments
 (0)