Skip to content

Commit 2845ed3

Browse files
refactor(Counter): recreate component
Co-Authored-By: Cristian C. Giraldo <52677987+imchriistian@users.noreply.github.com>
1 parent 8548504 commit 2845ed3

8 files changed

Lines changed: 405 additions & 85 deletions

File tree

bun.lock

Lines changed: 122 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"prisma:migrate": "prisma migrate dev"
1212
},
1313
"dependencies": {
14+
"@emotion/react": "^11.14.0",
15+
"@emotion/styled": "^11.14.1",
16+
"@js-temporal/polyfill": "^0.5.1",
17+
"@mui/icons-material": "^7.3.4",
18+
"@mui/material": "^7.3.4",
1419
"@prisma/client": "^6.17.1",
1520
"next": "15.5.6",
1621
"prisma": "^6.17.1",

src/app/page.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
"use client";
1+
'use client';
22

3-
import { useState } from "react";
4-
import { IncidentForm } from "@/components/IncidentForm";
5-
import { IncidentCard } from "@/components/IncidentCard";
6-
import { mockIncidents } from "@/lib/mockData";
7-
import { Incident } from "@/types/incident";
8-
import { Counter } from "@/components/Counter";
3+
import { useState } from 'react';
4+
import { IncidentForm } from '@/components/IncidentForm';
5+
import { mockIncidents } from '@/lib/mockData';
6+
import { Incident } from '@/types/incident';
7+
import { Counter } from '@/components/Counter';
98

109
export default function HomePage() {
1110
const [incidents, setIncidents] = useState<Incident[]>(mockIncidents);
@@ -15,22 +14,14 @@ export default function HomePage() {
1514
};
1615

1716
return (
18-
<div className="max-w-2xl mx-auto">
19-
<div className="flex justify-center my-6">
20-
<Counter />
21-
</div>
22-
17+
<div className='max-w-2xl mx-auto'>
2318
<IncidentForm onAddIncident={handleAddIncident} />
24-
<h2 className="text-lg font-semibold mt-8 mb-4 text-white">
25-
Incident History
26-
</h2>
27-
<div className="space-y-3">
19+
<h2 className='text-lg font-semibold mt-8 mb-4 text-white'>Incident History</h2>
20+
<div className='space-y-3'>
2821
{incidents.length === 0 ? (
29-
<p className="text-gray-500">No incidents recorded.</p>
22+
<p className='text-gray-500'>No incidents recorded.</p>
3023
) : (
31-
incidents.map((incident) => (
32-
<IncidentCard key={incident.id} incident={incident} />
33-
))
24+
incidents.map((incident) => <Counter key={incident.id} props={incident} />)
3425
)}
3526
</div>
3627
</div>

src/components/Counter.tsx

Lines changed: 194 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,206 @@
1-
"use client";
1+
import React, { useEffect, useState } from 'react';
2+
import { Temporal } from '@js-temporal/polyfill';
3+
import { WDXL_Lubrifont_JP_N } from 'next/font/google';
4+
import { Incident } from '@/types/incident';
5+
import { DeleteForever, ExpandMore, ExpandLess, RestartAlt, Info } from '@mui/icons-material';
26

3-
import { useEffect, useState } from "react";
7+
const WdxlLubrifontJpN = WDXL_Lubrifont_JP_N({
8+
subsets: ['latin'],
9+
weight: ['400'],
10+
});
411

5-
export function Counter() {
6-
const [daysWithoutIncidents, setDaysWithoutIncidents] = useState<number>(0);
12+
export function Counter({ props: { title, date, history, description } }: { props: Incident }) {
13+
const [shownDate, setShownDate] = useState<Temporal.Instant>(date);
14+
const [now, setNow] = useState(() => Temporal.Now.instant());
15+
const [showAccordion, setShowAccordion] = useState(false);
716

817
useEffect(() => {
9-
// Only using this to save the date of the last incident in localStorage
10-
const lastIncident = localStorage.getItem("lastIncidentDate");
11-
if (lastIncident) {
12-
const diff =
13-
(Date.now() - new Date(lastIncident).getTime()) / (1000 * 60 * 60 * 24);
14-
setDaysWithoutIncidents(Math.floor(diff));
18+
const interval = setInterval(() => {
19+
setNow(Temporal.Now.instant());
20+
}, 1000);
21+
return () => clearInterval(interval);
22+
}, []);
23+
24+
function resetDate() {
25+
const now = Temporal.Now.instant();
26+
history.push(now);
27+
setShownDate(now);
28+
}
29+
30+
function getBgColor(days: number) {
31+
if (days < 7) return 'bg-red-400';
32+
if (days < 30) return 'bg-yellow-400';
33+
if (days < 183) return 'bg-green-400';
34+
if (days >= 365) return 'bg-blue-400';
35+
return 'bg-green-500';
36+
}
37+
38+
function timeAgo({
39+
pastDate,
40+
targetDate,
41+
alwaysRelative,
42+
}: {
43+
pastDate: Temporal.Instant;
44+
targetDate: Temporal.Instant;
45+
alwaysRelative?: boolean;
46+
}) {
47+
const dateDiff = targetDate.since(pastDate, { largestUnit: 'auto' });
48+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
49+
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
50+
51+
const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, {
52+
numeric: 'auto',
53+
});
54+
const units: [Intl.RelativeTimeFormatUnit, number][] = [
55+
['second', 60],
56+
['minute', 60],
57+
['hour', 24],
58+
['day', 30],
59+
['month', 12],
60+
['year', Number.POSITIVE_INFINITY],
61+
];
62+
let delta = (targetDate.epochMilliseconds - pastDate.epochMilliseconds) / 1000;
63+
64+
if (alwaysRelative) {
65+
let value = delta;
66+
for (const [unit, limit] of units) {
67+
if (Math.abs(value) < limit) {
68+
return relativeTimeFormat.format(-Math.round(value), unit);
69+
}
70+
value /= limit;
71+
}
1572
} else {
16-
setDaysWithoutIncidents(0);
73+
if (dateDiff.months >= 1 || dateDiff.years >= 1) {
74+
const date = pastDate.toZonedDateTimeISO(timeZone);
75+
return date.toLocaleString(locale, {
76+
month: 'short',
77+
day: 'numeric',
78+
year: 'numeric',
79+
weekday: undefined,
80+
});
81+
}
82+
for (const [unit, limit] of units.slice(0, 4)) {
83+
if (Math.abs(delta) < limit) {
84+
return relativeTimeFormat.format(-Math.round(delta), unit);
85+
}
86+
delta /= limit;
87+
}
1788
}
18-
}, []);
1989

20-
const variantClass =
21-
daysWithoutIncidents === 0
22-
? "bg-red-600 text-white"
23-
: daysWithoutIncidents > 10
24-
? "bg-green-600 text-white"
25-
: daysWithoutIncidents > 5
26-
? "bg-yellow-400 text-black"
27-
: "bg-blue-600 text-white";
90+
const date = pastDate.toZonedDateTimeISO(timeZone);
91+
return date.toLocaleString(locale, {
92+
month: 'short',
93+
day: 'numeric',
94+
year: 'numeric',
95+
});
96+
}
97+
98+
const relative = timeAgo({ pastDate: shownDate, targetDate: now });
99+
const lastIncidentPlainDate = Temporal.PlainDate.from(shownDate.toString().slice(0, 10));
100+
const todayPlainDate = Temporal.PlainDate.from(now.toString().slice(0, 10));
101+
const days = lastIncidentPlainDate.until(todayPlainDate).days;
102+
const daysString = Math.max(0, days).toString().padStart(8, '0');
103+
const bgColor = getBgColor(days);
28104

29105
return (
30-
<div className={`${variantClass} px-6 py-4 rounded-lg shadow-md text-center w-full max-w-sm`}>
31-
<span className="text-xs block">Days without incidents</span>
32-
<span className="text-3xl font-bold">{daysWithoutIncidents}</span>
106+
<div className='bg-black text-white rounded-lg max-w-3xl w-full shadow-xl divide-y-2 divide-gray-900'>
107+
<div className='relative bg-black text-center rounded-t-lg py-3'>
108+
<div className='absolute left-4 top-1/2 -translate-y-1/2'>
109+
<span
110+
className={['block w-3.5 h-3.5 rounded-full animate-ping opacity-40 absolute', bgColor].join(' ')}
111+
></span>
112+
<span className={['block w-3.5 h-3.5 rounded-full relative', bgColor].join(' ')}></span>
113+
</div>
114+
115+
<span className='opacity-60 mr-2'>Days since</span>
116+
<span className='font-bold text-l'>{title}</span>
117+
<span className='relative group cursor-pointer' tabIndex={0} aria-label='Info'>
118+
<Info className='ml-1 text-blue-500' />
119+
<span
120+
className='absolute left-1/2 top-full z-10 mt-2 w-40 -translate-x-1/2 rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover:opacity-100 group-focus:opacity-100 pointer-events-none transition-opacity'
121+
role='tooltip'
122+
>
123+
{description}
124+
</span>
125+
</span>
126+
127+
<div className='absolute right-4 top-1/2 -translate-y-1/2'>
128+
<span
129+
className={['block w-3.5 h-3.5 rounded-full animate-ping opacity-40 absolute', bgColor].join(' ')}
130+
></span>
131+
<span className={['block w-3.5 h-3.5 rounded-full relative', bgColor].join(' ')}></span>
132+
</div>
133+
</div>
134+
135+
<div className='bg-white flex justify-center'>
136+
<div className='flex w-full divide-x-2 divide-gray-900'>
137+
{daysString.split('').map((digit, index) => (
138+
<span
139+
key={index}
140+
className={`${WdxlLubrifontJpN.className} bg-white text-black text-6xl leading-none flex items-center justify-center w-16 h-24 flex-1`}
141+
>
142+
{digit}
143+
</span>
144+
))}
145+
</div>
146+
</div>
147+
<div
148+
className={[
149+
'bg-yellow-200 overflow-hidden transition-[max-height,padding,overflow] duration-300',
150+
showAccordion ? 'max-h-48 overflow-y-auto px-4 py-3' : 'max-h-0 p-0 pointer-events-none',
151+
].join(' ')}
152+
>
153+
{!history.length ? (
154+
<p className='text-center text-gray-600'>No history yet.</p>
155+
) : (
156+
<ol className='relative border-s border-gray-200 dark:border-gray-700 space-y-3'>
157+
{[...history]
158+
.sort((a, b) => b.epochMilliseconds - a.epochMilliseconds)
159+
.map((date, key) => (
160+
<li key={key} className='ms-4'>
161+
<div className='absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700'></div>
162+
<time className='mb-1 text-sm font-normal leading-none text-gray-700 '>
163+
{date.toLocaleString()} (
164+
<span>{timeAgo({ pastDate: date, targetDate: now, alwaysRelative: true })}</span>)
165+
</time>
166+
</li>
167+
))}
168+
</ol>
169+
)}
170+
</div>
171+
172+
<div className='bg-yellow-400 rounded-b-lg text-black text-center py-3 px-4 text-base flex'>
173+
<div className='flex-3 grow-3 flex items-center justify-start'>
174+
<span className='opacity-60 mr-1'>Last:</span>
175+
<span>{relative}</span>
176+
</div>
177+
<div className='flex-1 grow flex items-center justify-end gap-2'>
178+
<button aria-label='Delete' className='bg-black text-red-400 px-3 py-1 rounded hover:bg-gray-800 transition'>
179+
<span aria-hidden='true'>
180+
<DeleteForever />
181+
</span>
182+
</button>
183+
<button
184+
aria-label='Reset'
185+
className='bg-black text-blue-400 px-3 py-1 rounded hover:bg-gray-800 transition'
186+
onClick={() => resetDate()}
187+
>
188+
<span aria-hidden='true'>
189+
<RestartAlt />
190+
</span>
191+
</button>
192+
<button
193+
aria-label={showAccordion ? 'Collapse section' : 'Expand section'}
194+
aria-expanded={showAccordion}
195+
className='bg-black text-white px-3 py-1 rounded hover:bg-gray-800 transition'
196+
onClick={() => setShowAccordion(!showAccordion)}
197+
>
198+
<span aria-hidden='true'>{showAccordion ? <ExpandLess /> : <ExpandMore />}</span>
199+
</button>
200+
</div>
201+
</div>
33202
</div>
34203
);
35204
}
205+
206+
export default Counter;

src/components/IncidentCard.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
import { Incident } from "@/types/incident";
2-
1+
import { Incident } from '@/types/incident';
32
interface Props {
43
incident: Incident;
54
}
65

76
export function IncidentCard({ incident }: Props) {
87
// Compute a deterministic date in DD-MM-YYYY using the ISO string to avoid hydration differences
9-
const isoDate = new Date(incident.date).toISOString().split("T")[0];
10-
const [year, month, day] = isoDate.split("-");
8+
const isoDate = incident.date.toString().split('T')[0];
9+
const [year, month, day] = isoDate.split('-');
1110
const formattedDate = `${day}-${month}-${year}`;
1211

1312
return (
14-
<div className="bg-white p-4 rounded-lg shadow-sm border">
15-
<h3 className="font-semibold text-black">{incident.title}</h3>
16-
<p className="text-sm text-gray-600 mt-1">{incident.description}</p>
17-
<span className="text-xs text-red-600 mt-2 block">{formattedDate}</span>
13+
<div className='bg-white p-4 rounded-lg shadow-sm border'>
14+
<h3 className='font-semibold text-black'>{incident.title}</h3>
15+
<p className='text-sm text-gray-600 mt-1'>{incident.description}</p>
16+
<span className='text-xs text-red-600 mt-2 block'>{formattedDate}</span>
1817
</div>
1918
);
2019
}

src/components/IncidentForm.tsx

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
"use client";
1+
'use client';
22

3-
import { useState } from "react";
4-
import { Incident } from "@/types/incident";
3+
import { useState } from 'react';
4+
import { Incident } from '@/types/incident';
5+
import { Temporal } from '@js-temporal/polyfill';
56

67
interface Props {
78
onAddIncident: (incident: Incident) => void;
89
}
910

1011
export function IncidentForm({ onAddIncident }: Props) {
11-
const [title, setTitle] = useState("");
12-
const [description, setDescription] = useState("");
12+
const [title, setTitle] = useState('');
13+
const [description, setDescription] = useState('');
1314

1415
const handleSubmit = (e: React.FormEvent) => {
1516
e.preventDefault();
@@ -19,44 +20,37 @@ export function IncidentForm({ onAddIncident }: Props) {
1920
id: Date.now(),
2021
title,
2122
description,
22-
date: new Date(),
23+
date: Temporal.Now.instant(),
24+
history: [],
2325
};
2426

2527
onAddIncident(newIncident);
2628

2729
// Save the date of the last incident in localStorage
28-
localStorage.setItem("lastIncidentDate", newIncident.date.toISOString());
30+
localStorage.setItem('lastIncidentDate', newIncident.date.toString());
2931

30-
setTitle("");
31-
setDescription("");
32+
setTitle('');
33+
setDescription('');
3234
};
3335

3436
return (
35-
<form
36-
onSubmit={handleSubmit}
37-
className="bg-white p-4 rounded-lg shadow-md space-y-3"
38-
>
39-
<h2 className="text-lg font-semibold text-black">
40-
Register New Incident
41-
</h2>
37+
<form onSubmit={handleSubmit} className='bg-white p-4 rounded-lg shadow-md space-y-3'>
38+
<h2 className='text-lg font-semibold text-black'>Register New Incident</h2>
4239
<input
43-
type="text"
44-
placeholder="Incident Title"
45-
className="w-full p-2 border rounded text-black"
40+
type='text'
41+
placeholder='Incident Title'
42+
className='w-full p-2 border rounded text-black'
4643
value={title}
4744
onChange={(e) => setTitle(e.target.value)}
4845
/>
4946
<textarea
50-
placeholder="Description"
51-
className="w-full p-2 text-black border rounded resize-none"
47+
placeholder='Description'
48+
className='w-full p-2 text-black border rounded resize-none'
5249
rows={3}
5350
value={description}
5451
onChange={(e) => setDescription(e.target.value)}
5552
/>
56-
<button
57-
type="submit"
58-
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
59-
>
53+
<button type='submit' className='bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition'>
6054
Save
6155
</button>
6256
</form>

0 commit comments

Comments
 (0)