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) - Real-time clock that automatically advances every second using the system time.
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - **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 - [Node.js](https://nodejs.org) (version 18 or later recommended)
export default defineConfig([ - npm, yarn, or pnpm
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this ### Installation
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs... ```bash
], # Clone the repository
languageOptions: { git clone <repository-url>
parserOptions: { cd <repository-folder>
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname, # Install dependencies
}, npm install
// other options...
},
},
]);
``` ```
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 ```bash
// eslint.config.js npm run dev
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...
},
},
]);
``` ```
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"); 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); 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() { function App() {
return ( 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 /> <TimeConverter />
</div> </div>
); );

View File

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

View File

@@ -1,20 +1,20 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from "@testing-library/react";
import { DigitalClock } from '../containers/DigitalClock'; import { DigitalClock } from "../containers/DigitalClock";
import * as utils from '../utils'; import * as utils from "../utils";
vi.mock('../utils'); vi.mock("../utils");
const mockedDateToDigital = vi.mocked(utils.dateToDigital); const mockedDateToDigital = vi.mocked(utils.dateToDigital);
const mockedDigitalToDate = vi.mocked(utils.digitalToDate); const mockedDigitalToDate = vi.mocked(utils.digitalToDate);
describe('DigitalClock', () => { describe("DigitalClock", () => {
const onTimeChangeMock = vi.fn(); const onTimeChangeMock = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedDateToDigital.mockReturnValue('14:30:15'); mockedDateToDigital.mockReturnValue("14:30:15");
mockedDigitalToDate.mockImplementation((str: string) => { mockedDigitalToDate.mockImplementation((str: string) => {
const parts = str.split(':'); const parts = str.split(":");
if (parts.length !== 3) return new Date(NaN); if (parts.length !== 3) return new Date(NaN);
const [h, m, s] = parts.map(Number); const [h, m, s] = parts.map(Number);
if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN); if (isNaN(h) || isNaN(m) || isNaN(s)) return new Date(NaN);
@@ -24,58 +24,57 @@ describe('DigitalClock', () => {
}); });
}); });
it('renders nothing when disabled', () => { it("renders the input as disabled when disabled prop is true", () => {
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', () => {
render( render(
<DigitalClock <DigitalClock
enabled={true} disabled={true}
currentTime={new Date(2025, 0, 1, 14, 30, 15)} currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock} onTimeChange={onTimeChangeMock}
/>, />,
); );
expect(screen.getByTestId('digital-clock')).toHaveTextContent('14:30:15'); const input = screen.getByTestId("time-input");
expect(screen.getByTestId('time-input')).toBeInTheDocument(); expect(input).toBeDisabled();
}); });
it('calls onTimeChange when a valid time is entered', () => { it("renders the input with the correct initial value when enabled", () => {
render( render(
<DigitalClock <DigitalClock
enabled={true}
currentTime={new Date(2025, 0, 1, 14, 30, 15)} currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock} onTimeChange={onTimeChangeMock}
/>, />,
); );
const input = screen.getByTestId('time-input'); const input = screen.getByTestId("time-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: '12:00:00' } }); 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); expect(onTimeChangeMock).toHaveBeenCalledTimes(1);
const expectedDate = new Date(2025, 0, 1, 12, 0, 0, 0); const expectedDate = new Date(2025, 0, 1, 12, 0, 0, 0);
expect(onTimeChangeMock).toHaveBeenCalledWith(expectedDate); 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( render(
<DigitalClock <DigitalClock
enabled={true}
currentTime={new Date(2025, 0, 1, 14, 30, 15)} currentTime={new Date(2025, 0, 1, 14, 30, 15)}
onTimeChange={onTimeChangeMock} onTimeChange={onTimeChangeMock}
/>, />,
); );
const input = screen.getByTestId('time-input'); const input = screen.getByTestId("time-input");
fireEvent.change(input, { target: { value: 'notatime' } }); fireEvent.change(input, { target: { value: "notatime" } });
expect(onTimeChangeMock).not.toHaveBeenCalled(); 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 { render } from "@testing-library/react";
import { Lamp } from '../components/Lamp'; import { Lamp } from "../components/Lamp";
import { LampState } from '../types'; import { LampState } from "../types";
describe('Lamp', () => { describe("Lamp", () => {
it('renders without crashing', () => { it("renders without crashing", () => {
render(<Lamp state={LampState.Y} />); render(<Lamp state={LampState.Y} />);
// If it doesn't throw, we're good. // If it doesn't throw, we're good.
}); });
it.each([ it.each([
['Y' as LampState, 'Y'], ["Y" as LampState, "Y"],
['R' as LampState, 'R'], ["R" as LampState, "R"],
['O' as LampState, 'O'], ["O" as LampState, "O"],
['N' as LampState, 'N'], ["N" as LampState, "N"],
])('displays aria-label "%s" for state %s', (state, expectedLabel) => { ])('displays aria-label "%s" for state %s', (state, expectedLabel) => {
const { container } = render(<Lamp state={state} />); const { container } = render(<Lamp state={state} />);
// The root element should have the correct aria-label // The root element should have the correct aria-label
const lampElement = container.firstChild as HTMLElement; 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 { render, screen } from "@testing-library/react";
import { LampLine } from '../components/LampLine'; import { LampLine } from "../components/LampLine";
import { LampState } from '../types'; import { LampState } from "../types";
describe('LampLine', () => { describe("LampLine", () => {
it('renders without crashing', () => { it("renders without crashing", () => {
render(<LampLine states={[]} />); 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]; const states = [LampState.Y, LampState.R, LampState.O];
render(<LampLine states={states} />); render(<LampLine states={states} />);
const container = screen.getByTestId('lamp-line'); const container = screen.getByTestId("lamp-line");
expect(container.children).toHaveLength(states.length); 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]; const states = [LampState.Y, LampState.R, LampState.O, LampState.N];
render(<LampLine states={states} />); render(<LampLine states={states} />);
const container = screen.getByTestId('lamp-line'); const container = screen.getByTestId("lamp-line");
const lampSpans = container.querySelectorAll('span'); const lampSpans = container.querySelectorAll("span");
expect(lampSpans).toHaveLength(states.length); expect(lampSpans).toHaveLength(states.length);
lampSpans.forEach((span, index) => { 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={[]} />); render(<LampLine states={[]} />);
const container = screen.getByTestId('lamp-line'); const container = screen.getByTestId("lamp-line");
expect(container.children).toHaveLength(0); expect(container.children).toHaveLength(0);
}); });
}); });

View File

@@ -1,28 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from "@testing-library/react";
import { TimeConverter } from '../containers/TimeConverter'; import { TimeConverter } from "../containers/TimeConverter";
import { useCurrentTime } from '../hooks/useCurrentTime'; import { useCurrentTime } from "../hooks/useCurrentTime";
vi.mock('../hooks/useCurrentTime'); vi.mock("../hooks/useCurrentTime");
vi.mock('../containers/BerlinClock', () => ({ vi.mock("../containers/BerlinClock", () => ({
BerlinClock: ({ enabled }: { enabled: boolean }) => ( 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', () => ({ vi.mock("../containers/DigitalClock", () => ({
DigitalClock: ({ enabled, currentTime, onTimeChange }: { enabled: boolean; currentTime: Date; onTimeChange: (d: Date) => void }) => ( 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"> <div data-testid="digital-clock-wrapper">
<span data-testid="digital-clock-time">{currentTime.toISOString()}</span> <input
<button data-testid="set-time" onClick={() => onTimeChange(new Date(2025,0,1,12,0,0))}> data-testid="time-input"
Set time disabled={disabled}
</button> value={digitalTime}
{!enabled && <span data-testid="disabled-indicator">disabled</span>} onChange={() => {}}
/>
</div> </div>
), );
},
})); }));
const mockedUseCurrentTime = vi.mocked(useCurrentTime); const mockedUseCurrentTime = vi.mocked(useCurrentTime);
describe('TimeConverter', () => { describe("TimeConverter", () => {
const setCurrentTimeMock = vi.fn(); const setCurrentTimeMock = vi.fn();
const baseDate = new Date(2025, 0, 1, 14, 30, 15); 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 />); render(<TimeConverter />);
expect(screen.getByTestId('use-system-time')).toBeInTheDocument(); expect(screen.getByTestId("use-system-time")).toBeInTheDocument();
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('enabled'); expect(screen.getByTestId("berlin-clock")).toHaveTextContent("enabled");
expect(screen.getByTestId('digital-clock-wrapper')).toBeInTheDocument(); 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 />); render(<TimeConverter />);
fireEvent.click(screen.getByTestId('use-system-time')); // Initially the input is enabled
expect(screen.getByTestId('berlin-clock')).toHaveTextContent('disabled'); expect(screen.getByTestId("time-input")).not.toBeDisabled();
expect(screen.getByTestId('disabled-indicator')).toBeInTheDocument(); // 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 />); render(<TimeConverter />);
fireEvent.click(screen.getByTestId('set-time')); const input = screen.getByTestId("time-input") as HTMLInputElement;
expect(setCurrentTimeMock).toHaveBeenCalledWith(new Date(2025, 0, 1, 12, 0, 0)); 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 { renderHook } from "@testing-library/react";
import { useCurrentTime } from '../hooks/useCurrentTime'; import { useCurrentTime } from "../hooks/useCurrentTime";
describe('useCurrentTime', () => { describe("useCurrentTime", () => {
it('returns a date and setter', () => { it("returns a date and setter", () => {
const { result } = renderHook(() => useCurrentTime()); const { result } = renderHook(() => useCurrentTime());
expect(result.current.currentTime).toBeInstanceOf(Date); 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 {
import { LampState } from '../types'; dateToDigital,
digitalToDate,
dateToBerlin,
berlinToDate,
} from "../utils";
import { LampState } from "../types";
describe('Utils', () => { describe("Utils", () => {
describe('dateToDigital', () => { describe("dateToDigital", () => {
it('returns "HH:mm:ss" for a given date', () => { it('returns "HH:mm:ss" for a given date', () => {
const date = new Date(2025, 0, 1, 14, 5, 9); 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); 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', () => { describe("digitalToDate", () => {
it('parses a valid digital string', () => { it("parses a valid digital string", () => {
const result = digitalToDate('12:34:56'); const result = digitalToDate("12:34:56");
expect(result).toBeInstanceOf(Date); expect(result).toBeInstanceOf(Date);
expect(result.getHours()).toBe(12); expect(result.getHours()).toBe(12);
expect(result.getMinutes()).toBe(34); expect(result.getMinutes()).toBe(34);
expect(result.getSeconds()).toBe(56); expect(result.getSeconds()).toBe(56);
}); });
it('returns an invalid date for invalid input', () => { it("returns an invalid date for invalid input", () => {
const result = digitalToDate('abc'); const result = digitalToDate("abc");
expect(isNaN(result.getTime())).toBe(true); expect(isNaN(result.getTime())).toBe(true);
}); });
}); });
describe('dateToBerlin', () => { describe("dateToBerlin", () => {
const makeTime = (h: number, m: number, s: number) => { const makeTime = (h: number, m: number, s: number) => {
const d = new Date(2025, 0, 1, h, m, s); const d = new Date(2025, 0, 1, h, m, s);
return d; 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)); const clock = dateToBerlin(makeTime(0, 0, 0));
expect(clock.secondsRow).toEqual([LampState.Y]); expect(clock.secondsRow).toEqual([LampState.Y]);
expect(clock.fiveHours).toEqual([LampState.O, LampState.O, LampState.O, LampState.O]); expect(clock.fiveHours).toEqual([
expect(clock.oneHour).toEqual([LampState.O, 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, 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)); const clock = dateToBerlin(makeTime(23, 59, 59));
expect(clock.secondsRow).toEqual([LampState.O]); expect(clock.secondsRow).toEqual([LampState.O]);
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.R, LampState.R]); expect(clock.fiveHours).toEqual([
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.R, LampState.O]); LampState.R,
expect(clock.fiveMinutes).toEqual([ LampState.R,
LampState.Y, LampState.Y, LampState.R, LampState.R,
LampState.Y, LampState.Y, LampState.R, LampState.R,
LampState.Y, LampState.Y, LampState.R, ]);
LampState.Y, LampState.Y, 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)); const clock = dateToBerlin(makeTime(12, 34, 0));
expect(clock.secondsRow).toEqual([LampState.Y]); expect(clock.secondsRow).toEqual([LampState.Y]);
expect(clock.fiveHours).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]); expect(clock.fiveHours).toEqual([
expect(clock.oneHour).toEqual([LampState.R, LampState.R, LampState.O, LampState.O]); LampState.R,
LampState.R,
LampState.O,
LampState.O,
]);
expect(clock.oneHour).toEqual([
LampState.R,
LampState.R,
LampState.O,
LampState.O,
]);
const expectedFiveMinutes = [ const expectedFiveMinutes = [
LampState.Y, LampState.Y, LampState.R, LampState.Y,
LampState.Y, LampState.Y, LampState.R, LampState.Y,
LampState.O, LampState.O, LampState.O, LampState.R,
LampState.O, LampState.O, LampState.Y,
LampState.Y,
LampState.R,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
LampState.O,
]; ];
expect(clock.fiveMinutes).toEqual(expectedFiveMinutes); 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', () => { describe("berlinToDate", () => {
it('roundtrips correctly: dateToBerlin then berlinToDate returns the original date (seconds floor to even)', () => { 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 original = new Date(2025, 0, 1, 14, 30, 0);
const clock = dateToBerlin(original); const clock = dateToBerlin(original);
const result = berlinToDate(clock); const result = berlinToDate(clock);
@@ -89,7 +160,7 @@ describe('Utils', () => {
expect(result.getSeconds()).toBe(0); 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 original = new Date(2025, 0, 1, 23, 59, 59);
const clock = dateToBerlin(original); const clock = dateToBerlin(original);
const result = berlinToDate(clock); const result = berlinToDate(clock);
@@ -98,16 +169,23 @@ describe('Utils', () => {
expect(result.getSeconds()).toBe(0); 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 = { const clock = {
secondsRow: [LampState.Y], secondsRow: [LampState.Y],
fiveHours: [LampState.R, LampState.R, LampState.O, LampState.O], fiveHours: [LampState.R, LampState.R, LampState.O, LampState.O],
oneHour: [LampState.R, LampState.R, LampState.O, LampState.O], oneHour: [LampState.R, LampState.R, LampState.O, LampState.O],
fiveMinutes: [ fiveMinutes: [
LampState.Y, LampState.Y, LampState.R, LampState.Y,
LampState.Y, LampState.Y, LampState.R, LampState.Y,
LampState.O, LampState.O, LampState.O, LampState.R,
LampState.O, LampState.O, 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], 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 { interface LampProps {
state: LampState; state: LampState;
id?: string; id?: string;
variant?: "alone" | "first" | "last" | "middle";
} }
export function Lamp({ state, id }: LampProps) { function getBackgroundColor(state: LampState): string {
return <span data-testid={id} aria-label={state} />; 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 { LampState } from "../types";
import { Lamp } from './Lamp'; import { Lamp } from "./Lamp";
interface LampLineProps { interface LampLineProps {
states: LampState[]; states: LampState[];
@@ -7,11 +7,36 @@ interface LampLineProps {
} }
export function LampLine({ states, rowTestId }: LampLineProps) { export function LampLine({ states, rowTestId }: LampLineProps) {
const len = states.length;
return ( return (
<div data-testid={rowTestId ?? "lamp-line"}> <div
{states.map((state, index) => ( data-testid={rowTestId ?? "lamp-line"}
<Lamp key={index} state={state} id={`lamp-${index}`} /> 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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap');
:root { :root {
--text: #6b6375; --text: #6b6375;
--text-h: #08060d; --text-h: #08060d;
@@ -55,10 +57,11 @@
max-width: 100%; max-width: 100%;
margin: 0 auto; margin: 0 auto;
text-align: center; text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -109,3 +112,26 @@ code {
padding: 4px 8px; padding: 4px 8px;
background: var(--code-bg); 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 { export enum LampState {
Y = 'Y', // Yellow Y = "Y", // Yellow
R = 'R', // Red R = "R", // Red
O = 'O', // Off O = "O", // Off
N = 'N', // None / undefined N = "N", // None / undefined
} }
export interface BerlinClock { export interface BerlinClock {

View File

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