Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 122 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@js-temporal/polyfill": "^0.5.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@prisma/client": "^6.17.1",
"next": "15.5.6",
"prisma": "^6.17.1",
Expand Down
31 changes: 11 additions & 20 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"use client";
'use client';

import { useState } from "react";
import { IncidentForm } from "@/components/IncidentForm";
import { IncidentCard } from "@/components/IncidentCard";
import { mockIncidents } from "@/lib/mockData";
import { Incident } from "@/types/incident";
import { Counter } from "@/components/Counter";
import { useState } from 'react';
import { IncidentForm } from '@/components/IncidentForm';
import { mockIncidents } from '@/lib/mockData';
import { Incident } from '@/types/global';
import { Counter } from '@/components/Counter';

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

return (
<div className="max-w-2xl mx-auto">
<div className="flex justify-center my-6">
<Counter />
</div>

<div className='max-w-2xl mx-auto'>
<IncidentForm onAddIncident={handleAddIncident} />
<h2 className="text-lg font-semibold mt-8 mb-4 text-white">
Incident History
</h2>
<div className="space-y-3">
<h2 className='text-lg font-semibold mt-8 mb-4 text-white'>Incident History</h2>
<div className='space-y-3'>
{incidents.length === 0 ? (
<p className="text-gray-500">No incidents recorded.</p>
<p className='text-gray-500'>No incidents recorded.</p>
) : (
incidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))
incidents.map((incident) => <Counter key={incident.id} props={incident} />)
Copy link

Copilot AI Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prop name 'props' is redundant and confusing. Consider renaming it to 'incident' to match the semantic meaning: <Counter key={incident.id} incident={incident} />

Suggested change
incidents.map((incident) => <Counter key={incident.id} props={incident} />)
incidents.map((incident) => <Counter key={incident.id} incident={incident} />)

Copilot uses AI. Check for mistakes.
)}
</div>
</div>
Expand Down
262 changes: 239 additions & 23 deletions src/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,251 @@
"use client";
import React, { useEffect, useState, useMemo } from 'react';
import { Temporal } from '@js-temporal/polyfill';
import { WDXL_Lubrifont_JP_N } from 'next/font/google';
import { Incident } from '@/types/global';
import { DeleteForever, ExpandMore, ExpandLess, RestartAlt, Info } from '@mui/icons-material';

import { useEffect, useState } from "react";
const WdxlLubrifontJpN = WDXL_Lubrifont_JP_N({
subsets: ['latin'],
weight: ['400'],
});

export function Counter() {
const [daysWithoutIncidents, setDaysWithoutIncidents] = useState<number>(0);
export function Counter({ props: { title, history, description } }: { props: Incident }) {
const sortedHistory = useMemo(
() => [...history].sort((a, b) => a.epochMilliseconds - b.epochMilliseconds),
[history]
);

const [shownDate, setShownDate] = useState<Temporal.Instant>(sortedHistory.at(-1) || Temporal.Now.instant());
const [now, setNow] = useState(() => Temporal.Now.instant());
const [showAccordion, setShowAccordion] = useState(false);

useEffect(() => {
// Only using this to save the date of the last incident in localStorage
const lastIncident = localStorage.getItem("lastIncidentDate");
if (lastIncident) {
const diff =
(Date.now() - new Date(lastIncident).getTime()) / (1000 * 60 * 60 * 24);
setDaysWithoutIncidents(Math.floor(diff));
const interval = setInterval(() => {
setNow(Temporal.Now.instant());
}, 1000);
return () => clearInterval(interval);
Comment on lines +23 to +26
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The interval updates state every second regardless of component visibility or user activity. Consider using requestAnimationFrame for better performance when the tab is active, or increase the interval (e.g., to 60 seconds) since the display shows days, not real-time seconds. Alternatively, add logic to pause the interval when the page is not visible using the Page Visibility API.

Suggested change
const interval = setInterval(() => {
setNow(Temporal.Now.instant());
}, 1000);
return () => clearInterval(interval);
let interval: NodeJS.Timeout | null = null;
function startInterval() {
if (!interval) {
interval = setInterval(() => {
setNow(Temporal.Now.instant());
}, 60000); // Update every 60 seconds
}
}
function stopInterval() {
if (interval) {
clearInterval(interval);
interval = null;
}
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
startInterval();
setNow(Temporal.Now.instant()); // Ensure immediate update on tab focus
} else {
stopInterval();
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
// Start interval if visible on mount
if (document.visibilityState === 'visible') {
startInterval();
}
return () => {
stopInterval();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};

Copilot uses AI. Check for mistakes.
}, []);

function resetDate() {
const now = Temporal.Now.instant();
sortedHistory.push(now);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating sortedHistory directly is problematic because it's a memoized value derived from the history prop. This mutation won't persist across re-renders and won't update the parent component's state. The resetDate function should call a callback prop (e.g., onAddHistory) to properly update the incident history in the parent component.

Copilot uses AI. Check for mistakes.
setShownDate(now);
}
Comment on lines +29 to +33
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating the sortedHistory array directly will not trigger React re-renders because it's a memoized value. This mutation also modifies the original history prop array. The function should create a new array and update the parent component's state through a callback prop, or at minimum avoid mutating the memoized value.

Copilot uses AI. Check for mistakes.

function getBgColor(days: number) {
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getBgColor function is defined inside the component body but doesn't use any component state or props. Consider moving it outside the component to avoid recreating it on every render.

Copilot uses AI. Check for mistakes.
if (days < 7) return 'bg-red-400';
if (days < 30) return 'bg-yellow-400';
if (days < 183) return 'bg-green-400';
if (days >= 365) return 'bg-blue-400';
return 'bg-green-500';
}

function timeAgo({
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeAgo function is defined inside the component body but doesn't use any component state or props besides what's passed as parameters. Consider moving it outside the component to avoid recreating it on every render.

Copilot uses AI. Check for mistakes.
pastDate,
targetDate,
alwaysRelative,
}: {
pastDate: Temporal.Instant;
targetDate: Temporal.Instant;
alwaysRelative?: boolean;
}) {
const dateDiff = targetDate.since(pastDate, { largestUnit: 'auto' });
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const locale = Intl.DateTimeFormat().resolvedOptions().locale;

const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto',
});
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['second', 60],
['minute', 60],
['hour', 24],
['day', 30],
['month', 12],
['year', Number.POSITIVE_INFINITY],
];
let delta = (targetDate.epochMilliseconds - pastDate.epochMilliseconds) / 1000;

if (alwaysRelative) {
let value = delta;
for (const [unit, limit] of units) {
if (Math.abs(value) < limit) {
return relativeTimeFormat.format(-Math.round(value), unit);
}
value /= limit;
}
} else {
setDaysWithoutIncidents(0);
if (dateDiff.months >= 1 || dateDiff.years >= 1) {
const date = pastDate.toZonedDateTimeISO(timeZone);
return date.toLocaleString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
weekday: undefined,
});
}
for (const [unit, limit] of units.slice(0, 4)) {
if (Math.abs(delta) < limit) {
return relativeTimeFormat.format(-Math.round(delta), unit);
}
delta /= limit;
}
}
}, []);

const variantClass =
daysWithoutIncidents === 0
? "bg-red-600 text-white"
: daysWithoutIncidents > 10
? "bg-green-600 text-white"
: daysWithoutIncidents > 5
? "bg-yellow-400 text-black"
: "bg-blue-600 text-white";
const date = pastDate.toZonedDateTimeISO(timeZone);
return date.toLocaleString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

function longestDaysRecord({ history, now }: { history: Temporal.Instant[]; now: Temporal.Instant }) {
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The longestDaysRecord function is defined inside the component body but doesn't use any component state or props besides what's passed as parameters. Consider moving it outside the component to avoid recreating it on every render.

Copilot uses AI. Check for mistakes.
if (history.length === 0) return 0;
if (history.length === 1) {
const onlyDatePlain = Temporal.PlainDate.from(history[0].toString().slice(0, 10));
const nowPlain = Temporal.PlainDate.from(now.toString().slice(0, 10));
return Math.max(0, onlyDatePlain.until(nowPlain).days);
}
const sortedReversedHistory = [...sortedHistory].reverse();
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The longestDaysRecord function creates a reversed copy of sortedHistory but sortedHistory is already available in the component scope and was computed using useMemo. Consider passing only the necessary data to this function or computing the reversed array outside the function to avoid redundant operations.

Copilot uses AI. Check for mistakes.
const toPlainDate = (inst: Temporal.Instant) => Temporal.PlainDate.from(inst.toString().slice(0, 10));

let maxGap = 0;
for (let i = 0; i < sortedReversedHistory.length - 1; i++) {
const a = toPlainDate(sortedReversedHistory[i]);
const b = toPlainDate(sortedReversedHistory[i + 1]);
const gap = a.until(b).days;
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gap calculation has reversed order. Since sortedReversedHistory is in descending order (most recent first), a is more recent than b. Calling a.until(b) produces a negative duration. The calculation should be b.until(a).days or use Math.abs(gap) to get the correct positive gap value.

Suggested change
const gap = a.until(b).days;
const gap = b.until(a).days;

Copilot uses AI. Check for mistakes.
if (gap > maxGap) maxGap = gap;
}

const lastPlain = toPlainDate(sortedReversedHistory[sortedReversedHistory.length - 1]);
Comment on lines +110 to +121
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sortedReversedHistory array is created inside the longestDaysRecord function but sortedHistory is already passed as a parameter. This creates unnecessary array copies. Consider passing the sorted history in the desired order or optimize the algorithm to avoid the reversal.

Suggested change
const sortedReversedHistory = [...sortedHistory].reverse();
const toPlainDate = (inst: Temporal.Instant) => Temporal.PlainDate.from(inst.toString().slice(0, 10));
let maxGap = 0;
for (let i = 0; i < sortedReversedHistory.length - 1; i++) {
const a = toPlainDate(sortedReversedHistory[i]);
const b = toPlainDate(sortedReversedHistory[i + 1]);
const gap = a.until(b).days;
if (gap > maxGap) maxGap = gap;
}
const lastPlain = toPlainDate(sortedReversedHistory[sortedReversedHistory.length - 1]);
const toPlainDate = (inst: Temporal.Instant) => Temporal.PlainDate.from(inst.toString().slice(0, 10));
let maxGap = 0;
// Iterate over sortedHistory in reverse order
for (let i = history.length - 1; i > 0; i--) {
const a = toPlainDate(history[i]);
const b = toPlainDate(history[i - 1]);
const gap = a.until(b).days;
if (gap > maxGap) maxGap = gap;
}
const lastPlain = toPlainDate(history[0]);

Copilot uses AI. Check for mistakes.
const nowPlain = Temporal.PlainDate.from(now.toString().slice(0, 10));
const lastGap = lastPlain.until(nowPlain).days;
if (lastGap > maxGap) maxGap = lastGap;

return Math.max(0, Math.floor(maxGap));
}

const relative = timeAgo({ pastDate: shownDate, targetDate: now });
const lastIncidentPlainDate = Temporal.PlainDate.from(shownDate.toString().slice(0, 10));
const todayPlainDate = Temporal.PlainDate.from(now.toString().slice(0, 10));
const days = lastIncidentPlainDate.until(todayPlainDate).days;
const daysString = Math.max(0, days).toString().padStart(8, '0');
const bgColor = getBgColor(days);

return (
<div className={`${variantClass} px-6 py-4 rounded-lg shadow-md text-center w-full max-w-sm`}>
<span className="text-xs block">Days without incidents</span>
<span className="text-3xl font-bold">{daysWithoutIncidents}</span>
<div className='bg-black text-white rounded-lg max-w-3xl w-full shadow-xl divide-y-2 divide-gray-900'>
<div className='relative bg-black text-center rounded-t-lg py-3'>
<div className='absolute left-4 top-1/2 -translate-y-1/2'>
<span
className={['block w-3.5 h-3.5 rounded-full animate-ping opacity-40 absolute', bgColor].join(' ')}
></span>
<span className={['block w-3.5 h-3.5 rounded-full relative', bgColor].join(' ')}></span>
</div>

<span className='opacity-60 mr-2'>Days since</span>
<span className='font-bold text-l'>{title}</span>
<span className='relative group cursor-pointer' tabIndex={0} aria-label='Info'>
<Info className='ml-1 text-blue-500' />
<span
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'
role='tooltip'
>
{description}
</span>
</span>

<div className='absolute right-4 top-1/2 -translate-y-1/2'>
<span
className={['block w-3.5 h-3.5 rounded-full animate-ping opacity-40 absolute', bgColor].join(' ')}
></span>
<span className={['block w-3.5 h-3.5 rounded-full relative', bgColor].join(' ')}></span>
</div>
</div>

<div className='bg-white flex justify-center'>
<div className='flex w-full divide-x-2 divide-gray-900'>
{daysString.split('').map((digit, index) => (
<span
key={index}
className={[
WdxlLubrifontJpN.className,
'bg-white text-black text-6xl leading-none flex items-center justify-center w-16 h-24 flex-1',
].join(' ')}
>
{digit}
</span>
))}
</div>
</div>
<div
className={[
'bg-yellow-200 overflow-hidden transition-[max-height,padding,overflow] duration-300',
showAccordion ? 'max-h-48 overflow-y-auto px-4 py-3' : 'max-h-0 p-0 pointer-events-none',
].join(' ')}
>
{(() => {
const sortedReversedHistory = [...sortedHistory].reverse();
const entriesToShow = sortedReversedHistory.length > 1 ? sortedReversedHistory.slice(1) : [];

if (!entriesToShow.length) {
return <p className='text-center text-gray-600'>No history yet.</p>;
}
return (
<ol className='relative border-s border-gray-200 dark:border-gray-700 space-y-3'>
{entriesToShow.map((value, index) => (
<li key={index} className='ms-4'>
<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>
<time className='mb-1 text-sm font-normal leading-none text-gray-700 '>
{value.toLocaleString()} (
<span>{timeAgo({ pastDate: value, targetDate: now, alwaysRelative: true })}</span>)
</time>
</li>
))}
</ol>
);
})()}
</div>
<div className='bg-yellow-400 rounded-b-lg text-black text-center py-3 px-4 text-base flex'>
<div className='flex-3 grow-3 flex flex-col items-start justify-center space-y-1'>
<div className='flex items-center'>
<span className='opacity-60 mr-1'>Last:</span>
<span>{relative}</span>
</div>
{sortedHistory.length > 1 && (
<div className='flex items-center'>
<span className='opacity-60 mr-1'>Record:</span>
<span>{longestDaysRecord({ history: sortedHistory, now })} days</span>
</div>
)}
</div>
<div className='flex-1 grow flex items-center justify-end gap-2'>
<button aria-label='Delete' className='bg-black text-red-400 px-3 py-1 rounded hover:bg-gray-800 transition'>
<span aria-hidden='true'>
<DeleteForever />
</span>
</button>
Comment on lines +223 to +227
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Delete button is missing an onClick handler, making it non-functional. Add an onClick handler or a callback prop to handle incident deletion.

Copilot uses AI. Check for mistakes.
<button
aria-label='Reset'
className='bg-black text-blue-400 px-3 py-1 rounded hover:bg-gray-800 transition'
onClick={() => resetDate()}
>
<span aria-hidden='true'>
<RestartAlt />
</span>
</button>
<button
aria-label={showAccordion ? 'Collapse section' : 'Expand section'}
aria-expanded={showAccordion}
className='bg-black text-white px-3 py-1 rounded hover:bg-gray-800 transition'
onClick={() => setShowAccordion(!showAccordion)}
>
<span aria-hidden='true'>{showAccordion ? <ExpandLess /> : <ExpandMore />}</span>
</button>
</div>
</div>
</div>
);
}

export default Counter;
15 changes: 7 additions & 8 deletions src/components/IncidentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { Incident } from "@/types/incident";

import { Incident } from '@/types/global';
interface Props {
incident: Incident;
}

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

return (
<div className="bg-white p-4 rounded-lg shadow-sm border">
<h3 className="font-semibold text-black">{incident.title}</h3>
<p className="text-sm text-gray-600 mt-1">{incident.description}</p>
<span className="text-xs text-red-600 mt-2 block">{formattedDate}</span>
<div className='bg-white p-4 rounded-lg shadow-sm border'>
<h3 className='font-semibold text-black'>{incident.title}</h3>
<p className='text-sm text-gray-600 mt-1'>{incident.description}</p>
<span className='text-xs text-red-600 mt-2 block'>{formattedDate}</span>
</div>
);
}
Loading
Loading