Skip to content

Commit 0ec3426

Browse files
authored
fix: make engineer tenure timezone independent (#504)
* fix: make engineer tenure timezone independent * fix: cap month-end anchors in calendar duration * refactor: extract calendar and rename tenure card
1 parent 7c7154e commit 0ec3426

6 files changed

Lines changed: 155 additions & 20 deletions

File tree

components/dashboard-card/days-as-engineer.tsx

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { TimeAsSoftwareEngineer } from '@/components/dashboard-card/time-as-engineer';
4+
5+
describe('TimeAsSoftwareEngineer', () => {
6+
beforeEach(() => {
7+
jest.useFakeTimers().setSystemTime(new Date('2026-07-19T12:00:00Z'));
8+
});
9+
10+
afterEach(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
it('should render elapsed time as years, months and days', () => {
15+
render(<TimeAsSoftwareEngineer />);
16+
17+
expect(screen.getByText('7y, 11m, 18d')).toBeInTheDocument();
18+
});
19+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { DashboardCard } from '@/components/dashboard-card/dashboard-card';
4+
import { calendarDurationBetween } from '@/lib/calendar';
5+
6+
const CAREER_START_DATE = new Date('2018-08-01');
7+
8+
export function TimeAsSoftwareEngineer() {
9+
const [formattedDuration, setFormattedDuration] = useState<string>();
10+
11+
// Computed after mount: the page is statically prerendered, so a
12+
// day-precise value baked at build time would mismatch on hydration.
13+
useEffect(() => {
14+
const { years, months, days } = calendarDurationBetween(
15+
CAREER_START_DATE,
16+
new Date(),
17+
);
18+
setFormattedDuration(`${years}y, ${months}m, ${days}d`);
19+
}, []);
20+
21+
return (
22+
<DashboardCard
23+
header="Time as Software Engineer"
24+
metric={formattedDuration}
25+
/>
26+
);
27+
}

lib/calendar.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { calendarDurationBetween } from '@/lib/calendar';
2+
3+
describe('calendarDurationBetween', () => {
4+
const start = new Date('2018-08-01');
5+
6+
it('should count full years, months and days since start', () => {
7+
expect(
8+
calendarDurationBetween(start, new Date('2026-07-19T12:00:00Z')),
9+
).toEqual({ years: 7, months: 11, days: 18 });
10+
});
11+
12+
it('should roll over to a new year on the anniversary date', () => {
13+
expect(
14+
calendarDurationBetween(start, new Date('2026-08-01T00:00:00Z')),
15+
).toEqual({ years: 8, months: 0, days: 0 });
16+
});
17+
18+
it('should stay at the previous year until the anniversary', () => {
19+
expect(
20+
calendarDurationBetween(start, new Date('2026-07-31T23:59:59Z')),
21+
).toEqual({ years: 7, months: 11, days: 30 });
22+
});
23+
24+
it('should borrow days from the previous month when end day is smaller', () => {
25+
expect(
26+
calendarDurationBetween(
27+
new Date('2018-08-15'),
28+
new Date('2026-07-10T00:00:00Z'),
29+
),
30+
).toEqual({ years: 7, months: 10, days: 25 });
31+
});
32+
33+
it('should compute in UTC regardless of local timezone offset', () => {
34+
// 23:30 in New York (UTC-4) is already July 20 in UTC
35+
expect(
36+
calendarDurationBetween(start, new Date('2026-07-19T23:30:00-04:00')),
37+
).toEqual({ years: 7, months: 11, days: 19 });
38+
});
39+
40+
it('should cap month-end anchors to shorter months', () => {
41+
expect(
42+
calendarDurationBetween(new Date('2020-01-31'), new Date('2020-03-01')),
43+
).toEqual({ years: 0, months: 1, days: 1 });
44+
});
45+
46+
it('should treat a capped february as a full month', () => {
47+
expect(
48+
calendarDurationBetween(new Date('2020-01-31'), new Date('2021-02-28')),
49+
).toEqual({ years: 1, months: 1, days: 0 });
50+
});
51+
52+
it('should return zeros for the same instant', () => {
53+
expect(calendarDurationBetween(start, new Date('2018-08-01'))).toEqual({
54+
years: 0,
55+
months: 0,
56+
days: 0,
57+
});
58+
});
59+
});

lib/calendar.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface CalendarDuration {
2+
years: number;
3+
months: number;
4+
days: number;
5+
}
6+
7+
function daysInUTCMonth(year: number, monthIndex: number): number {
8+
// Day 0 of the next month is the last day of monthIndex
9+
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
10+
}
11+
12+
/**
13+
* Calendar duration between two instants, computed in UTC so the
14+
* result does not depend on the viewer's timezone. Month-end anchors
15+
* cap to shorter months (Jan 31 + 1 month = Feb 29), so days are
16+
* never negative — the same convention as date-fns.
17+
*/
18+
export function calendarDurationBetween(
19+
start: Date,
20+
end: Date,
21+
): CalendarDuration {
22+
let years = end.getUTCFullYear() - start.getUTCFullYear();
23+
let months = end.getUTCMonth() - start.getUTCMonth();
24+
25+
const anchorDay = Math.min(
26+
start.getUTCDate(),
27+
daysInUTCMonth(end.getUTCFullYear(), end.getUTCMonth()),
28+
);
29+
let days = end.getUTCDate() - anchorDay;
30+
31+
if (days < 0) {
32+
months -= 1;
33+
const prevMonthDays = daysInUTCMonth(
34+
end.getUTCFullYear(),
35+
end.getUTCMonth() - 1,
36+
);
37+
days =
38+
end.getUTCDate() +
39+
prevMonthDays -
40+
Math.min(start.getUTCDate(), prevMonthDays);
41+
}
42+
if (months < 0) {
43+
years -= 1;
44+
months += 12;
45+
}
46+
47+
return { years, months, days };
48+
}

pages/dashboard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import Head from 'next/head';
22

33
import { BlogViewsCard } from '@/components/dashboard-card/blog-views';
4-
import { DaysAsSoftwareEngineer } from '@/components/dashboard-card/days-as-engineer';
54
import { GithubFollowers } from '@/components/dashboard-card/github-followers';
65
import { GitHubStars } from '@/components/dashboard-card/github-stars';
76
import { MonthlyUsers } from '@/components/dashboard-card/monthly-user';
7+
import { TimeAsSoftwareEngineer } from '@/components/dashboard-card/time-as-engineer';
88

99
export default function Dashboard() {
1010
return (
@@ -53,7 +53,7 @@ export default function Dashboard() {
5353
Statistics
5454
</h2>
5555
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 my-2 w-full">
56-
<DaysAsSoftwareEngineer />
56+
<TimeAsSoftwareEngineer />
5757
<BlogViewsCard />
5858
<GithubFollowers />
5959
<GitHubStars />

0 commit comments

Comments
 (0)