Compare commits

..

10 Commits

21 changed files with 603 additions and 274 deletions

145
README.md
View File

@@ -1,73 +1,100 @@
# React + TypeScript + Vite
# Berlin Clock (Mengenlehreuhr)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
A React + TypeScript application that implements the **Berlin Clock** (also known as the Mengenlehreuhr), a system invented in Berlin in 1975 to represent the time using coloured lamps.
Currently, two official plugins are available:
## Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
- Real-time clock that automatically advances every second using the system time.
- **Converter mode** uncheck the "Use system time" checkbox to switch to manual time input. The digital time display becomes editable, letting you set any time and immediately see its BerlinClock representation.
- Full Berlin Clock layout:
- **Seconds lamp** (top, round) blinks yellow every second.
- **Fivehour row** four red lamps, each representing 5 hours.
- **Onehour row** four red lamps, each representing 1 hour.
- **Fiveminute row** eleven lamps, each representing 5 minutes. Every third lamp is red to mark quarter hours; the remaining lamps are yellow.
- **Oneminute row** four yellow lamps, each representing 1 minute.
## React Compiler
## Technologies
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
- [React](https://react.dev)
- [TypeScript](https://www.typescriptlang.org)
- [Vite](https://vitejs.dev)
- [Cypress](https://www.cypress.io) (endtoend tests)
- [Vitest](https://vitest.dev) (unit tests, via the community)
## Expanding the ESLint configuration
## Getting Started
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
### Prerequisites
```js
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
- [Node.js](https://nodejs.org) (version 18 or later recommended)
- npm, yarn, or pnpm
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
### Installation
// Other configs...
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```bash
# Clone the repository
git clone <repository-url>
cd <repository-folder>
# Install dependencies
npm install
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
### Running the Development Server
```js
// eslint.config.js
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser to see the application.
### Building for Production
```bash
npm run build
```
The output will be placed in the `dist` directory.
### Running Tests
```bash
# Unit tests (Vitest)
npm test
# Endtoend tests (Cypress)
npx cypress run
# Open Cypress test runner
npx cypress open
```
## Usage
When the application starts, the Berlin Clock shows the **current system time** (the "Use system time" checkbox is checked).
- **System clock mode** the digital display shows the current time and is readonly. The Berlin clock updates every second.
- **Manual converter mode** uncheck **"Use system time"**. The digital time input becomes editable. Type a new time (hh:mm:ss) or use the browsers native date/time picker (if available). As you change the value, the Berlin Clock immediately reflects the entered time.
> The Berlin Clock layout is readonly; it always shows the time represented by the digital input.
## Project Structure
```
src/
├── App.tsx Main application component
├── components/
│ ├── Lamp.tsx Single lamp with state and border radius logic
│ └── LampLine.tsx Row of lamps (used for fivehour, onehour, etc.)
├── containers/
│ ├── BerlinClock.tsx Visual representation of the Berlin clock
│ ├── DigitalClock.tsx Input field for digital time
│ └── TimeConverter.tsx Orchestrates clock switching and time sync
├── hooks/
│ └── useCurrentTime.ts Hook to manage current time state
├── types/
│ └── index.ts Type definitions (`LampState`, `BerlinClock`)
├── utils/
│ └── index.ts Conversion functions between Date and Berlin Clock
└── __tests__/ Unit tests corresponding to the source files
```

View File

@@ -209,4 +209,42 @@ describe("Berlin Clock", () => {
assertFullClock("11:37:01", "ORROOROOOYYRYYRYOOOOYYOO");
});
});
describe("Use System Time", () => {
it("should update every second and the seconds lamp should blink", () => {
// Freeze the clock at a known time (even seconds e.g. 0, so lamp is Y)
const now = new Date();
now.setSeconds(0, 0); // seconds = 0 → even → lamp = Y
cy.clock(now);
// Visit the page (clock is frozen from this point)
cy.visit("/");
// Check the "Use system time" checkbox
cy.get('[data-testid="use-system-time"]').check();
// Verify initial seconds lamp is Y (seconds=0)
cy.get('[data-testid="seconds-lamp"]').should(
"have.attr",
"aria-label",
"Y",
);
// Advance time by 1 second → seconds=1 (odd) → lamp toggles to O
cy.tick(1000);
cy.get('[data-testid="seconds-lamp"]').should(
"have.attr",
"aria-label",
"O",
);
// Advance another second → seconds=2 (even) → lamp back to Y
cy.tick(1000);
cy.get('[data-testid="seconds-lamp"]').should(
"have.attr",
"aria-label",
"Y",
);
});
});
});

View File

@@ -182,3 +182,24 @@
border-right-color: var(--border);
}
}
.berlin-clock {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.seconds-lamp-container {
margin-bottom: 4px;
display: flex;
justify-content: center;
}
.lamp-row {
display: flex;
justify-content: center;
width: 100%;
max-width: 330px;
margin: 0 auto;
}

View File

@@ -3,7 +3,15 @@ import "./App.css";
function App() {
return (
<div>
<div className="clock-container">
<h1>Berlin Clock</h1>
<p>
The Berlin Clock (also known as the Mengenlehreuhr) uses lamps to represent the time.
The top lamp blinks every second. The next row of four red lamps each represents five hours.
The following row of four red lamps each represents one hour.
Then the next row of eleven lamps shows fiveminute blocks (every third lamp is red to
mark quarter hours). The last row shows the remaining minutes one by one.
</p>
<TimeConverter />
</div>
);

View File

@@ -1,54 +1,61 @@
import { render, screen } from '@testing-library/react';
import { BerlinClock } from '../containers/BerlinClock';
import { LampState } from '../types';
import { render, screen } from "@testing-library/react";
import { BerlinClock } from "../containers/BerlinClock";
import { LampState } from "../types";
describe('BerlinClock', () => {
describe("BerlinClock", () => {
const testClock = {
secondsRow: [LampState.Y],
fiveHours: [LampState.R, LampState.R, LampState.R, LampState.O],
oneHour: [LampState.R, LampState.R, LampState.R, LampState.R],
fiveMinutes: [
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O,
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
LampState.R,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
],
oneMinute: [LampState.O, LampState.O, LampState.O, LampState.O],
};
it('renders nothing when disabled', () => {
const { container } = render(<BerlinClock enabled={false} clock={testClock} />);
expect(container.innerHTML).toBe('');
it("renders nothing when disabled", () => {
const { container } = render(
<BerlinClock enabled={false} clock={testClock} />,
);
expect(container.innerHTML).toBe("");
});
it('renders all five rows when enabled', () => {
it("renders all five rows when enabled", () => {
render(<BerlinClock enabled={true} clock={testClock} />);
expect(screen.getByTestId('seconds-row')).toBeInTheDocument();
expect(screen.getByTestId('five-hours-row')).toBeInTheDocument();
expect(screen.getByTestId('single-hours-row')).toBeInTheDocument();
expect(screen.getByTestId('five-minutes-row')).toBeInTheDocument();
expect(screen.getByTestId('single-minutes-row')).toBeInTheDocument();
expect(screen.getByTestId("seconds-row")).toBeInTheDocument();
expect(screen.getByTestId("five-hours-row")).toBeInTheDocument();
expect(screen.getByTestId("single-hours-row")).toBeInTheDocument();
expect(screen.getByTestId("five-minutes-row")).toBeInTheDocument();
expect(screen.getByTestId("single-minutes-row")).toBeInTheDocument();
});
it('renders the correct number of lamps in seconds-row', () => {
it("renders the correct number of lamps in seconds-row", () => {
render(<BerlinClock enabled={true} clock={testClock} />);
const secRow = screen.getByTestId('seconds-row');
const lampLine = secRow.querySelector('[data-testid="lamp-line"]');
expect(lampLine?.children).toHaveLength(1);
const secRow = screen.getByTestId("seconds-row");
expect(secRow.children).toHaveLength(1);
});
it('renders the correct number of lamps in five-hours-row', () => {
it("renders the correct number of lamps in five-hours-row", () => {
render(<BerlinClock enabled={true} clock={testClock} />);
const row = screen.getByTestId('five-hours-row');
const lampLine = row.querySelector('[data-testid="lamp-line"]');
expect(lampLine?.children).toHaveLength(4);
const row = screen.getByTestId("five-hours-row");
expect(row.children).toHaveLength(4);
});
it('renders correct aria-labels in the seconds lamp', () => {
it("renders correct aria-labels in the seconds lamp", () => {
render(<BerlinClock enabled={true} clock={testClock} />);
const secRow = screen.getByTestId('seconds-row');
const span = secRow.querySelector('span');
expect(span).toHaveAttribute('aria-label', 'Y');
const secRow = screen.getByTestId("seconds-row");
const span = secRow.querySelector("span");
expect(span).toHaveAttribute("aria-label", "Y");
});
});

View File

@@ -1,20 +1,20 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { DigitalClock } from '../containers/DigitalClock';
import * as utils from '../utils';
import { render, screen, fireEvent } from "@testing-library/react";
import { DigitalClock } from "../containers/DigitalClock";
import * as utils from "../utils";
vi.mock('../utils');
vi.mock("../utils");
const mockedDateToDigital = vi.mocked(utils.dateToDigital);
const mockedDigitalToDate = vi.mocked(utils.digitalToDate);
describe('DigitalClock', () => {
describe("DigitalClock", () => {
const onTimeChangeMock = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockedDateToDigital.mockReturnValue('14:30:15');
mockedDateToDigital.mockReturnValue("14:30:15");
mockedDigitalToDate.mockImplementation((str: string) => {
const parts = str.split(':');
const parts = str.split(":");
if (parts.length !== 3) return new Date(NaN);
const [h, m, s] = parts.map(Number);
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
@@ -24,58 +24,57 @@ describe('DigitalClock', () => {
});
});
it('renders nothing when disabled', () => {
const { container } = render(
<DigitalClock
enabled={false}
currentTime={new Date(2025,0,1,14,30,15)}
onTimeChange={onTimeChangeMock}
/>,
);
expect(container.innerHTML).toBe('');
});
it('renders the digital time and input when enabled', () => {
it("renders the input as disabled when disabled prop is true", () => {
render(
<DigitalClock
enabled={true}
currentTime={new Date(2025,0,1,14,30,15)}
disabled={true}
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock}
/>,
);
expect(screen.getByTestId('digital-clock')).toHaveTextContent('14:30:15');
expect(screen.getByTestId('time-input')).toBeInTheDocument();
const input = screen.getByTestId("time-input");
expect(input).toBeDisabled();
});
it('calls onTimeChange when a valid time is entered', () => {
it("renders the input with the correct initial value when enabled", () => {
render(
<DigitalClock
enabled={true}
currentTime={new Date(2025,0,1,14,30,15)}
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock}
/>,
);
const input = screen.getByTestId('time-input');
fireEvent.change(input, { target: { value: '12:00:00' } });
const input = screen.getByTestId("time-input") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe("14:30:15");
});
it("calls onTimeChange when a valid time is entered", () => {
render(
<DigitalClock
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock}
/>,
);
const input = screen.getByTestId("time-input");
fireEvent.change(input, { target: { value: "12:00:00" } });
expect(onTimeChangeMock).toHaveBeenCalledTimes(1);
const expectedDate = new Date(2025, 0, 1, 12, 0, 0, 0);
expect(onTimeChangeMock).toHaveBeenCalledWith(expectedDate);
expect(input).toHaveValue('12:00:00');
expect(input).toHaveValue("12:00:00");
});
it('does not call onTimeChange when an invalid time is entered', () => {
it("does not call onTimeChange when an invalid time is entered", () => {
render(
<DigitalClock
enabled={true}
currentTime={new Date(2025,0,1,14,30,15)}
currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock}
/>,
);
const input = screen.getByTestId('time-input');
fireEvent.change(input, { target: { value: 'notatime' } });
const input = screen.getByTestId("time-input");
fireEvent.change(input, { target: { value: "notatime" } });
expect(onTimeChangeMock).not.toHaveBeenCalled();
expect(input).toHaveValue('notatime');
expect(input).toHaveValue("notatime");
});
});

View File

@@ -1,22 +1,22 @@
import { render } from '@testing-library/react';
import { Lamp } from '../components/Lamp';
import { LampState } from '../types';
import { render } from "@testing-library/react";
import { Lamp } from "../components/Lamp";
import { LampState } from "../types";
describe('Lamp', () => {
it('renders without crashing', () => {
describe("Lamp", () => {
it("renders without crashing", () => {
render(<Lamp state={LampState.Y} />);
// If it doesn't throw, we're good.
});
it.each([
['Y' as LampState, 'Y'],
['R' as LampState, 'R'],
['O' as LampState, 'O'],
['N' as LampState, 'N'],
["Y" as LampState, "Y"],
["R" as LampState, "R"],
["O" as LampState, "O"],
["N" as LampState, "N"],
])('displays aria-label "%s" for state %s', (state, expectedLabel) => {
const { container } = render(<Lamp state={state} />);
// The root element should have the correct aria-label
const lampElement = container.firstChild as HTMLElement;
expect(lampElement).toHaveAttribute('aria-label', expectedLabel);
expect(lampElement).toHaveAttribute("aria-label", expectedLabel);
});
});

View File

@@ -1,33 +1,33 @@
import { render, screen } from '@testing-library/react';
import { LampLine } from '../components/LampLine';
import { LampState } from '../types';
import { render, screen } from "@testing-library/react";
import { LampLine } from "../components/LampLine";
import { LampState } from "../types";
describe('LampLine', () => {
it('renders without crashing', () => {
describe("LampLine", () => {
it("renders without crashing", () => {
render(<LampLine states={[]} />);
});
it('renders the correct number of lamps', () => {
it("renders the correct number of lamps", () => {
const states = [LampState.Y, LampState.R, LampState.O];
render(<LampLine states={states} />);
const container = screen.getByTestId('lamp-line');
const container = screen.getByTestId("lamp-line");
expect(container.children).toHaveLength(states.length);
});
it('displays correct aria-labels for each lamp', () => {
it("displays correct aria-labels for each lamp", () => {
const states = [LampState.Y, LampState.R, LampState.O, LampState.N];
render(<LampLine states={states} />);
const container = screen.getByTestId('lamp-line');
const lampSpans = container.querySelectorAll('span');
const container = screen.getByTestId("lamp-line");
const lampSpans = container.querySelectorAll("span");
expect(lampSpans).toHaveLength(states.length);
lampSpans.forEach((span, index) => {
expect(span).toHaveAttribute('aria-label', states[index]);
expect(span).toHaveAttribute("aria-label", states[index]);
});
});
it('renders no lamps when given an empty array', () => {
it("renders no lamps when given an empty array", () => {
render(<LampLine states={[]} />);
const container = screen.getByTestId('lamp-line');
const container = screen.getByTestId("lamp-line");
expect(container.children).toHaveLength(0);
});
});

View File

@@ -1,28 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { TimeConverter } from '../containers/TimeConverter';
import { useCurrentTime } from '../hooks/useCurrentTime';
import { render, screen, fireEvent } from "@testing-library/react";
import { TimeConverter } from "../containers/TimeConverter";
import { useCurrentTime } from "../hooks/useCurrentTime";
vi.mock('../hooks/useCurrentTime');
vi.mock('../containers/BerlinClock', () => ({
vi.mock("../hooks/useCurrentTime");
vi.mock("../containers/BerlinClock", () => ({
BerlinClock: ({ enabled }: { enabled: boolean }) => (
<div data-testid="berlin-clock">{enabled ? 'enabled' : 'disabled'}</div>
<div data-testid="berlin-clock">{enabled ? "enabled" : "disabled"}</div>
),
}));
vi.mock('../containers/DigitalClock', () => ({
DigitalClock: ({ enabled, currentTime, onTimeChange }: { enabled: boolean; currentTime: Date; onTimeChange: (d: Date) => void }) => (
vi.mock("../containers/DigitalClock", () => ({
DigitalClock: ({
currentTime,
onTimeChange,
disabled = false,
}: {
currentTime: Date;
onTimeChange: (d: Date) => void;
disabled?: boolean;
}) => {
const digitalTime = `${String(currentTime.getHours()).padStart(2, "0")}:${String(currentTime.getMinutes()).padStart(2, "0")}:${String(currentTime.getSeconds()).padStart(2, "0")}`;
return (
<div data-testid="digital-clock-wrapper">
<span data-testid="digital-clock-time">{currentTime.toISOString()}</span>
<button data-testid="set-time" onClick={() => onTimeChange(new Date(2025,0,1,12,0,0))}>
Set time
</button>
{!enabled && <span data-testid="disabled-indicator">disabled</span>}
<input
data-testid="time-input"
disabled={disabled}
value={digitalTime}
onChange={() => {}}
/>
</div>
),
);
},
}));
const mockedUseCurrentTime = vi.mocked(useCurrentTime);
describe('TimeConverter', () => {
describe("TimeConverter", () => {
const setCurrentTimeMock = vi.fn();
const baseDate = new Date(2025, 0, 1, 14, 30, 15);
@@ -34,23 +46,65 @@ describe('TimeConverter', () => {
});
});
it('renders the checkbox, BerlinClock, and DigitalClock', () => {
it("renders the checkbox, BerlinClock, and DigitalClock", () => {
render(<TimeConverter />);
expect(screen.getByTestId('use-system-time')).toBeInTheDocument();
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('enabled');
expect(screen.getByTestId('digital-clock-wrapper')).toBeInTheDocument();
expect(screen.getByTestId("use-system-time")).toBeInTheDocument();
expect(screen.getByTestId("berlin-clock")).toHaveTextContent("enabled");
expect(screen.getByTestId("digital-clock-wrapper")).toBeInTheDocument();
});
it('disables both clocks when checkbox is checked', () => {
it("disables the digital clock input when checkbox is checked", () => {
render(<TimeConverter />);
fireEvent.click(screen.getByTestId('use-system-time'));
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('disabled');
expect(screen.getByTestId('disabled-indicator')).toBeInTheDocument();
// Initially the input is enabled
expect(screen.getByTestId("time-input")).not.toBeDisabled();
// Click the checkbox
fireEvent.click(screen.getByTestId("use-system-time"));
// Now the input should be disabled
expect(screen.getByTestId("time-input")).toBeDisabled();
// But the BerlinClock still appears enabled (no "disabled" text)
expect(screen.getByTestId("berlin-clock")).toHaveTextContent("enabled");
});
it('calls setCurrentTime when DigitalClock triggers onTimeChange', () => {
it("renders the digital clock input with the correct initial value", () => {
render(<TimeConverter />);
fireEvent.click(screen.getByTestId('set-time'));
expect(setCurrentTimeMock).toHaveBeenCalledWith(new Date(2025, 0, 1, 12, 0, 0));
const input = screen.getByTestId("time-input") as HTMLInputElement;
expect(input.value).toBe("14:30:15");
});
// ──────────────────────────────────────────────────────────
// NEW TDD TEST should FAIL with current component code
// ──────────────────────────────────────────────────────────
it("switches to system time when 'Use system time' checkbox is checked (TDD)", () => {
// Fix system time to 15:00:00
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 0, 1, 15, 0, 0));
// A mutable currentTime that the mock will return
let currentDate = new Date(2025, 0, 1, 12, 0, 0);
const setCurrentDate = vi.fn((d: Date) => {
currentDate = d;
});
mockedUseCurrentTime.mockImplementation(() => ({
currentTime: currentDate,
setCurrentTime: setCurrentDate,
}));
render(<TimeConverter />);
// Initially the digital clock input shows the manual time 12:00:00
const input = screen.getByTestId("time-input") as HTMLInputElement;
expect(input.value).toBe("12:00:00");
// Click the "Use system time" checkbox
fireEvent.click(screen.getByTestId("use-system-time"));
// The component should have called setCurrentDate with the system time
expect(setCurrentDate).toHaveBeenCalledWith(new Date(2025, 0, 1, 15, 0, 0));
// And the input should now show the system time 15:00:00
expect(input.value).toBe("15:00:00");
vi.useRealTimers();
});
});

View File

@@ -1,10 +1,10 @@
import { renderHook, act } from '@testing-library/react';
import { useCurrentTime } from '../hooks/useCurrentTime';
import { renderHook } from "@testing-library/react";
import { useCurrentTime } from "../hooks/useCurrentTime";
describe('useCurrentTime', () => {
it('returns a date and setter', () => {
describe("useCurrentTime", () => {
it("returns a date and setter", () => {
const { result } = renderHook(() => useCurrentTime());
expect(result.current.currentTime).toBeInstanceOf(Date);
expect(typeof result.current.setCurrentTime).toBe('function');
expect(typeof result.current.setCurrentTime).toBe("function");
});
});

View File

@@ -1,86 +1,157 @@
import { dateToDigital, digitalToDate, dateToBerlin, berlinToDate } from '../utils';
import { LampState } from '../types';
import {
dateToDigital,
digitalToDate,
dateToBerlin,
berlinToDate,
} from "../utils";
import { LampState } from "../types";
describe('Utils', () => {
describe('dateToDigital', () => {
describe("Utils", () => {
describe("dateToDigital", () => {
it('returns "HH:mm:ss" for a given date', () => {
const date = new Date(2025, 0, 1, 14, 5, 9);
expect(dateToDigital(date)).toBe('14:05:09');
expect(dateToDigital(date)).toBe("14:05:09");
});
it('pads single-digit hours, minutes, seconds', () => {
it("pads single-digit hours, minutes, seconds", () => {
const date = new Date(2025, 0, 1, 2, 3, 4);
expect(dateToDigital(date)).toBe('02:03:04');
expect(dateToDigital(date)).toBe("02:03:04");
});
});
describe('digitalToDate', () => {
it('parses a valid digital string', () => {
const result = digitalToDate('12:34:56');
describe("digitalToDate", () => {
it("parses a valid digital string", () => {
const result = digitalToDate("12:34:56");
expect(result).toBeInstanceOf(Date);
expect(result.getHours()).toBe(12);
expect(result.getMinutes()).toBe(34);
expect(result.getSeconds()).toBe(56);
});
it('returns an invalid date for invalid input', () => {
const result = digitalToDate('abc');
it("returns an invalid date for invalid input", () => {
const result = digitalToDate("abc");
expect(isNaN(result.getTime())).toBe(true);
});
});
describe('dateToBerlin', () => {
describe("dateToBerlin", () => {
const makeTime = (h: number, m: number, s: number) => {
const d = new Date(2025, 0, 1, h, m, s);
return d;
};
it('returns correct clock for 00:00:00', () => {
it("returns correct clock for 00:00:00", () => {
const clock = dateToBerlin(makeTime(0, 0, 0));
expect(clock.secondsRow).toEqual([LampState.Y]);
expect(clock.fiveHours).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
expect(clock.oneHour).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
expect(clock.fiveMinutes).toEqual([
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O,
expect(clock.fiveHours).toEqual([
LampState.O,
LampState.O,
LampState.O,
LampState.O,
]);
expect(clock.oneHour).toEqual([
LampState.O,
LampState.O,
LampState.O,
LampState.O,
]);
expect(clock.fiveMinutes).toEqual([
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
]);
expect(clock.oneMinute).toEqual([
LampState.O,
LampState.O,
LampState.O,
LampState.O,
]);
expect(clock.oneMinute).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]);
});
it('returns correct clock for 23:59:59', () => {
it("returns correct clock for 23:59:59", () => {
const clock = dateToBerlin(makeTime(23, 59, 59));
expect(clock.secondsRow).toEqual([LampState.O]);
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.R, LampState.R]);
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.R, LampState.O]);
expect(clock.fiveMinutes).toEqual([
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y,
expect(clock.fiveHours).toEqual([
LampState.R,
LampState.R,
LampState.R,
LampState.R,
]);
expect(clock.oneHour).toEqual([
LampState.R,
LampState.R,
LampState.R,
LampState.O,
]);
expect(clock.fiveMinutes).toEqual([
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
]);
expect(clock.oneMinute).toEqual([
LampState.Y,
LampState.Y,
LampState.Y,
LampState.Y,
]);
expect(clock.oneMinute).toEqual([LampState.Y, LampState.Y, LampState.Y, LampState.Y]);
});
it('returns correct clock for 12:34:00', () => {
it("returns correct clock for 12:34:00", () => {
const clock = dateToBerlin(makeTime(12, 34, 0));
expect(clock.secondsRow).toEqual([LampState.Y]);
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]);
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]);
expect(clock.fiveHours).toEqual([
LampState.R,
LampState.R,
LampState.O,
LampState.O,
]);
expect(clock.oneHour).toEqual([
LampState.R,
LampState.R,
LampState.O,
LampState.O,
]);
const expectedFiveMinutes = [
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O,
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
LampState.R,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
];
expect(clock.fiveMinutes).toEqual(expectedFiveMinutes);
expect(clock.oneMinute).toEqual([LampState.Y, LampState.Y, LampState.Y, LampState.Y]);
expect(clock.oneMinute).toEqual([
LampState.Y,
LampState.Y,
LampState.Y,
LampState.Y,
]);
});
});
describe('berlinToDate', () => {
it('roundtrips correctly: dateToBerlin then berlinToDate returns the original date (seconds floor to even)', () => {
describe("berlinToDate", () => {
it("roundtrips correctly: dateToBerlin then berlinToDate returns the original date (seconds floor to even)", () => {
const original = new Date(2025, 0, 1, 14, 30, 0);
const clock = dateToBerlin(original);
const result = berlinToDate(clock);
@@ -89,7 +160,7 @@ describe('Utils', () => {
expect(result.getSeconds()).toBe(0);
});
it('roundtrips with odd seconds: result seconds will be zero (only parity stored)', () => {
it("roundtrips with odd seconds: result seconds will be zero (only parity stored)", () => {
const original = new Date(2025, 0, 1, 23, 59, 59);
const clock = dateToBerlin(original);
const result = berlinToDate(clock);
@@ -98,16 +169,23 @@ describe('Utils', () => {
expect(result.getSeconds()).toBe(0);
});
it('converts a known Berlin clock to the correct time', () => {
it("converts a known Berlin clock to the correct time", () => {
const clock = {
secondsRow: [LampState.Y],
fiveHours: [LampState.R, LampState.R, LampState.O, LampState.O],
oneHour: [LampState.R, LampState.R, LampState.O, LampState.O],
fiveMinutes: [
LampState.Y, LampState.Y, LampState.R,
LampState.Y, LampState.Y, LampState.R,
LampState.O, LampState.O, LampState.O,
LampState.O, LampState.O,
LampState.Y,
LampState.Y,
LampState.R,
LampState.Y,
LampState.Y,
LampState.R,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
],
oneMinute: [LampState.Y, LampState.Y, LampState.Y, LampState.Y],
};

View File

@@ -1,10 +1,35 @@
import { LampState } from '../types';
import React from "react";
import { LampState } from "../types";
interface LampProps {
state: LampState;
id?: string;
variant?: "alone" | "first" | "last" | "middle";
}
export function Lamp({ state, id }: LampProps) {
return <span data-testid={id} aria-label={state} />;
function getBackgroundColor(state: LampState): string {
if (state === LampState.R) return "#ff3333";
if (state === LampState.Y) return "#ffcc00";
return "#333";
}
function getBorderRadius(variant?: LampProps["variant"]): string {
if (variant === "alone") return "50%";
if (variant === "first") return "16px 0 0 16px";
if (variant === "last") return "0 16px 16px 0";
return "0";
}
export function Lamp({ state, id, variant }: LampProps) {
const style: React.CSSProperties = {
display: "block",
width: variant === "alone" ? 50 : "100%",
height: 50,
backgroundColor: getBackgroundColor(state),
borderRadius: getBorderRadius(variant),
margin: 1,
transition: "background-color 0.2s",
};
return <span data-testid={id} aria-label={state} style={style} />;
}

View File

@@ -1,5 +1,5 @@
import { LampState } from '../types';
import { Lamp } from './Lamp';
import { LampState } from "../types";
import { Lamp } from "./Lamp";
interface LampLineProps {
states: LampState[];
@@ -7,11 +7,36 @@ interface LampLineProps {
}
export function LampLine({ states, rowTestId }: LampLineProps) {
const len = states.length;
return (
<div data-testid={rowTestId ?? "lamp-line"}>
{states.map((state, index) => (
<Lamp key={index} state={state} id={`lamp-${index}`} />
))}
<div
data-testid={rowTestId ?? "lamp-line"}
style={{ display: "flex", marginBottom: 10, width: "100%", justifyContent: "space-evenly" }}
>
{states.map((state, index) => {
let variant: "alone" | "first" | "last" | "middle" | undefined;
if (len === 1) {
variant = "alone";
} else if (index === 0) {
variant = "first";
} else if (index === len - 1) {
variant = "last";
} else {
variant = "middle";
}
return (
<div
key={index}
style={{ flex: 1, display: "flex" }}
>
<Lamp
state={state}
id={`lamp-${index}`}
variant={variant}
/>
</div>
);
})}
</div>
);
}

View File

@@ -11,14 +11,22 @@ export function BerlinClock({ enabled, clock }: BerlinClockProps) {
if (!enabled) return null;
return (
<div>
<div data-testid="seconds-row">
<Lamp state={clock.secondsRow[0]} id="seconds-lamp" />
<div className="berlin-clock" style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
<div className="seconds-lamp-container" data-testid="seconds-row" style={{ width: "100%", display: "flex", justifyContent: "center" }}>
<Lamp state={clock.secondsRow[0]} variant="alone" id="seconds-lamp" />
</div>
<div className="lamp-row" style={{ width: "100%" }}>
<LampLine states={clock.fiveHours} rowTestId="five-hours-row" />
</div>
<div className="lamp-row" style={{ width: "100%" }}>
<LampLine states={clock.oneHour} rowTestId="single-hours-row" />
</div>
<div className="lamp-row" style={{ width: "100%" }}>
<LampLine states={clock.fiveMinutes} rowTestId="five-minutes-row" />
</div>
<div className="lamp-row" style={{ width: "100%" }}>
<LampLine states={clock.oneMinute} rowTestId="single-minutes-row" />
</div>
</div>
);
}

View File

@@ -1,13 +1,17 @@
import { useState } from 'react';
import { dateToDigital, digitalToDate } from '../utils';
import { useState } from "react";
import { dateToDigital, digitalToDate } from "../utils";
interface DigitalClockProps {
enabled: boolean;
currentTime: Date;
onTimeChange: (time: Date) => void;
disabled?: boolean;
}
export function DigitalClock({ enabled, currentTime, onTimeChange }: DigitalClockProps) {
export function DigitalClock({
currentTime,
onTimeChange,
disabled = false,
}: DigitalClockProps) {
const [inputValue, setInputValue] = useState(dateToDigital(currentTime));
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -19,17 +23,15 @@ export function DigitalClock({ enabled, currentTime, onTimeChange }: DigitalCloc
}
};
if (!enabled) return null;
return (
<div>
<span data-testid="digital-clock">{dateToDigital(currentTime)}</span>
<input
data-testid="time-input"
type="text"
value={inputValue}
onChange={handleChange}
placeholder="HH:MM:SS"
disabled={disabled}
/>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { BerlinClock } from "./BerlinClock";
import { DigitalClock } from "./DigitalClock";
import { useCurrentTime } from "../hooks/useCurrentTime";
@@ -6,27 +6,39 @@ import { dateToBerlin } from "../utils";
export function TimeConverter() {
const { currentTime, setCurrentTime } = useCurrentTime();
const [useSystemTime, setUseSystemTime] = useState(false);
const enabled = !useSystemTime;
const [useSystemTime, setUseSystemTime] = useState(true);
const berlinClock = dateToBerlin(currentTime);
useEffect(() => {
if (!useSystemTime) return;
const id = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(id);
}, [useSystemTime, setCurrentTime]);
return (
<div>
<div className="time-converter">
<BerlinClock enabled={true} clock={berlinClock} />
<DigitalClock
currentTime={currentTime}
onTimeChange={setCurrentTime}
disabled={useSystemTime}
/>
<label>
<input
type="checkbox"
data-testid="use-system-time"
checked={useSystemTime}
onChange={(e) => setUseSystemTime(e.target.checked)}
onChange={(e) => {
setUseSystemTime(e.target.checked);
if (e.target.checked) {
setCurrentTime(new Date());
}
}}
/>
Use system time
</label>
<BerlinClock enabled={enabled} clock={berlinClock} />
<DigitalClock
enabled={enabled}
currentTime={currentTime}
onTimeChange={setCurrentTime}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
export function useCurrentTime(): {
currentTime: Date;

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap');
:root {
--text: #6b6375;
--text-h: #08060d;
@@ -55,10 +57,11 @@
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
@@ -109,3 +112,26 @@ code {
padding: 4px 8px;
background: var(--code-bg);
}
.clock-container {
font-family: 'Orbitron', monospace;
}
.clock-container input {
font-family: inherit;
border: none;
outline: none;
display: block;
width: 100%;
height: 50px;
font-size: 28px;
text-align: center;
margin: 16px auto;
border-radius: 8px;
background: var(--code-bg);
color: var(--text-h);
}
.time-converter > :first-child {
margin-top: 20px;
}

View File

@@ -1 +1 @@
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";

View File

@@ -1,8 +1,8 @@
export enum LampState {
Y = 'Y', // Yellow
R = 'R', // Red
O = 'O', // Off
N = 'N', // None / undefined
Y = "Y", // Yellow
R = "R", // Red
O = "O", // Off
N = "N", // None / undefined
}
export interface BerlinClock {

View File

@@ -1,13 +1,13 @@
import type { BerlinClock } from '../types';
import { LampState } from '../types';
import type { BerlinClock } from "../types";
import { LampState } from "../types";
export function dateToDigital(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
const pad = (n: number) => n.toString().padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
export function digitalToDate(digital: string): Date {
const parts = digital.split(':');
const parts = digital.split(":");
if (parts.length !== 3) return new Date(NaN);
const [h, m, s] = parts.map(Number);
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
@@ -65,8 +65,7 @@ export function berlinToDate(berlin: BerlinClock): Date {
arr.filter((s) => s === LampState.R || s === LampState.Y).length;
const hours = countR(berlin.fiveHours) * 5 + countR(berlin.oneHour);
const minutes =
countYorR(berlin.fiveMinutes) * 5 + countY(berlin.oneMinute);
const minutes = countYorR(berlin.fiveMinutes) * 5 + countY(berlin.oneMinute);
const d = new Date();
d.setHours(hours, minutes, 0, 0);