Skip to content

Commit a968aa7

Browse files
committed
feat: add the NEW component "WeekRenderer" to render ISO week numbers
1 parent 5cc0ccb commit a968aa7

File tree

10 files changed

+380
-2
lines changed

10 files changed

+380
-2
lines changed

.storybook/utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
/**
2+
* Removes the "key" attribute from the story component source code
3+
* Note: the "key" attribute should be removed, since it is only used to allow dynamic rendering of the story.
4+
*
5+
* @param code the original component code used to produce the story
6+
* @returns the component code to be displayed in the story
7+
*/
18
export const RemoveKeyAttribute = (code: string) => {
29
return code.replace(/^.*key.*$\n/gm, "")
310
}
11+
12+
/**
13+
* timeZoneSamples is used by the DateRenderer and the TimeRenderer stories
14+
*/
15+
const timeZoneSamples = {
16+
PacificNiue: ["Pacific/Niue", "-11"],
17+
PacificGalapagos: ["Pacific/Galapagos", "-9"],
18+
EuropeDublin: ["Europe/Dublin", "+0/+1"],
19+
EuropeParis: ["Europe/Paris", "+1/+2"],
20+
IndianMauritius: ["Indian/Mauritius", "+4"],
21+
EtcGMTMinus8: ["Etc/GMT-8", "+8"],
22+
PacificTongatapu: ["Pacific/Tongatapu", "+13"],
23+
}
24+
export const timeZoneLabels = Object.keys(timeZoneSamples).map((tz) => `${timeZoneSamples[tz][0]} (${timeZoneSamples[tz][1]})`);
25+
export const timeZoneOptions = Object.keys(timeZoneSamples).map((_, i)=> i);
26+
export const timeZoneMappings = Object.keys(timeZoneSamples).map((tz) => timeZoneSamples[tz][0]);
27+
28+
/**
29+
* numberingSystemSamples is used by the WeekRenderer story
30+
*/
31+
const numberingSystemSamples = {
32+
arab: "Arabic-Indic digits",
33+
brah: "Brahmi digits",
34+
deva: "Devanagari digits",
35+
hanidec: "Chinese number ideographs",
36+
latn: "Latin digits",
37+
tibt: "Tibetan digits"
38+
}
39+
export const numberingSystemLabels = Object.keys(numberingSystemSamples).map((tz) => numberingSystemSamples[tz] );
40+
export const numberingSystemOptions = Object.keys(numberingSystemSamples).map((_, i)=> i);
41+
export const numberingSystemMappings = Object.keys(numberingSystemSamples);

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Please read the [CHANGELOG](./CHANGELOG.md) (all changes since the beginning) an
3131
- `DateRenderer` component ([Storybook → DateRenderer](https://khatastroffik.github.io/react-text-renderer-components/?path=/docs/components-daterenderer--daterenderer-documentation))
3232
- `TimeRenderer` component ([Storybook → TimeRenderer](https://khatastroffik.github.io/react-text-renderer-components/?path=/docs/components-timerenderer--timerenderer-documentation))
3333
- `DateTimeRenderer` component ([Storybook → DateTimeRenderer](https://khatastroffik.github.io/react-text-renderer-components/?path=/docs/components-datetimerenderer--datetimerenderer-documentation))
34+
- `WeekRenderer` component ([Storybook → WeekRenderer](https://khatastroffik.github.io/react-text-renderer-components/?path=/docs/components-weekrenderer--weekrenderer-documentation))
3435

3536
more components to come... (see the ToDos below)
3637

@@ -101,7 +102,7 @@ This design allows to avoid repetitions, reduce the size of the compiled code us
101102

102103
### Implement supplemental renderer components
103104

104-
- `WeekRenderer` component
105+
- `WeekRenderer` component
105106
- ⬛ `QuarterRenderer` component
106107
- ⬛ `TextRenderer` component (with text manipulation like UpperCase, LowerCase, Replace...)
107108
- ⬛ `CurrencyRenderer` component
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Primary, Canvas, Source, Meta, ArgTypes, Description, Controls } from '@storybook/blocks';
2+
import * as WeekRendererStories from './WeekRenderer.stories';
3+
4+
<Meta of={WeekRendererStories} name="WeekRenderer Documentation" />
5+
6+
# WeekRenderer
7+
8+
<Description of={WeekRendererStories} />
9+
10+
### Preview
11+
12+
<Primary />
13+
14+
### Properties
15+
16+
The text representation can be customized using the appropriates tag attributes i.e. component properties, as described below.
17+
18+
<Controls of={WeekRendererStories.Playground} />
19+
{/* <ArgTypes of={WeekRendererStories} sort="requiredFirst" /> */}
20+
21+
Notes:
22+
23+
- The values and options used/available within the "Control" column are for demonstration purpose only.
24+
- Use the "Reset controls" button on the top right side of the table in order to restore the initial demo values and options. Alternatively, you could reload the page.
25+
26+
### Usage
27+
28+
<Source dark type={"jsx"} code={`
29+
import { WeekRenderer } from 'react-text-renderer-components';
30+
31+
const Example = () => {
32+
return <WeekRenderer value={new Date()} />
33+
}`
34+
} />
35+
36+
Back to the [Github Repository](https://github.com/khatastroffik/react-text-renderer-components)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React from "react";
2+
import { render } from '@testing-library/react';
3+
import { WeekRenderer, calcISOWeek, defaultWeekRendererFormatOptions } from "./WeekRenderer";
4+
5+
const dateString = "2025-12-29T11:11:11.111Z";
6+
const dateValue = new Date(dateString);
7+
8+
describe("WeekRenderer component", () => {
9+
10+
it("should render a week number of a date value to a pure 2-digits string", () => {
11+
const r = render(<WeekRenderer value={dateValue} pure />);
12+
const x = calcISOWeek(dateValue);
13+
const formatter = new Intl.NumberFormat(undefined, defaultWeekRendererFormatOptions);
14+
const manuallyLocalizedWeekNumberString = formatter.format(x.week);
15+
16+
expect(r.container.innerHTML).toEqual(manuallyLocalizedWeekNumberString);
17+
18+
});
19+
20+
it("should render a week number of a date value to a pure localized 1-digit string", () => {
21+
const r = render(<WeekRenderer value={dateValue} pure minimumIntegerDigits={1} />);
22+
const x = calcISOWeek(dateValue);
23+
const formatter = new Intl.NumberFormat(undefined, { ...defaultWeekRendererFormatOptions, minimumIntegerDigits: 1 });
24+
const manuallyLocalizedWeekNumberString = formatter.format(x.week);
25+
26+
expect(r.container.innerHTML).toEqual(manuallyLocalizedWeekNumberString);
27+
});
28+
29+
it("should render a week number of a date value to a pure string according to a given numbering system", () => {
30+
const r = render(<WeekRenderer value={dateValue} pure numberingSystem="tibt" />);
31+
const x = calcISOWeek(dateValue);
32+
const formatter = new Intl.NumberFormat(undefined, { ...defaultWeekRendererFormatOptions, numberingSystem: "tibt" });
33+
const manuallyFormatedWeekNumberString = formatter.format(x.week);
34+
35+
expect(r.container.innerHTML).toEqual(manuallyFormatedWeekNumberString);
36+
});
37+
38+
39+
it("should render a week number of a date value to a pure specifically localized string with year of the week", () => {
40+
const r = render(<WeekRenderer value={dateValue} displayYear pure />);
41+
const x = calcISOWeek(dateValue);
42+
const formatter = new Intl.NumberFormat(undefined, defaultWeekRendererFormatOptions);
43+
const manuallyLocalizedWeekNumberString = `${formatter.format(x.week)}/${x.year}`;
44+
45+
expect(r.container.innerHTML).toEqual(manuallyLocalizedWeekNumberString);
46+
});
47+
48+
49+
it("should render an empty string when a date value is missing", () => {
50+
const r = render(<WeekRenderer value={null} pure />);
51+
52+
expect(r.container.innerHTML).toEqual("");
53+
});
54+
55+
it("should render correct week numbers for 'edge case' date values", () => {
56+
expect(render(<WeekRenderer value={dateValue} displayYear pure />).container.innerHTML).toEqual("01/2026");
57+
expect(render(<WeekRenderer value={new Date("2024-12-30T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("01/2025");
58+
expect(render(<WeekRenderer value={new Date("2025-01-01T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("01/2025");
59+
expect(render(<WeekRenderer value={new Date("2026-12-28T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("53/2026");
60+
expect(render(<WeekRenderer value={new Date("2027-01-03T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("53/2026");
61+
expect(render(<WeekRenderer value={new Date("2027-01-04T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("01/2027");
62+
expect(render(<WeekRenderer value={new Date("2025-12-29T11:11:11.111Z")} displayYear pure />).container.innerHTML).toEqual("01/2026");
63+
});
64+
65+
it("protected method 'getFormatedText()' should return a localized week number as string", () => {
66+
class WeekRendererWrapper extends WeekRenderer {
67+
public getFormatedText() {
68+
return super.getFormatedText();
69+
}
70+
}
71+
const component = new WeekRendererWrapper({ value: dateValue });
72+
const automaticallyLocalizedWeekNumberString = new Intl.NumberFormat(undefined, defaultWeekRendererFormatOptions).format(calcISOWeek(dateValue).week);
73+
74+
expect(component.getFormatedText()).toEqual(automaticallyLocalizedWeekNumberString);
75+
});
76+
77+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import { WeekRenderer } from './WeekRenderer';
4+
import { numberingSystemLabels, numberingSystemMappings, numberingSystemOptions, RemoveKeyAttribute } from "../../../.storybook/utils";
5+
6+
const testdate = new Date("2024-10-06T19:40:55.221Z");
7+
8+
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9+
10+
const meta = {
11+
title: 'Components/WeekRenderer',
12+
component: WeekRenderer,
13+
argTypes: {
14+
value: {
15+
control: "date",
16+
description: "The **value** from which the *date* part should be rendered.",
17+
},
18+
pure: {
19+
control: "boolean",
20+
description: "If **pure** is set/defined, then the component will render nothing but the 'pure' text representation of the value. Otherwise, the text representation is embeded within a *&lt;span&gt;&lt;/span&gt;* tag. (default: false/unset)",
21+
},
22+
minimumIntegerDigits: {
23+
control: { type: "range", min: 1, max: 2, step: 1 },
24+
description: "this parameter defines the amount of displayed digits (1 or 2) for the week number when the value is < 10. (default: 2 digits).",
25+
defaultValue: undefined
26+
},
27+
displayYear: {
28+
control: "boolean",
29+
description: "Should the year corresponding to the week number be displayed (e.g. '53/2026')? (default: false/unset)"
30+
},
31+
numberingSystem: {
32+
control: { type: "select", labels: numberingSystemLabels },
33+
description: "this parameter defines the 'design' of the displayed digits. (default: unset → client numbering system)",
34+
options: numberingSystemOptions,
35+
mapping: numberingSystemMappings
36+
}
37+
},
38+
parameters: {
39+
docs: {
40+
controls: { sort: "requiredFirst" },
41+
source: { language: 'tsx', type: 'auto', transform: RemoveKeyAttribute },
42+
description: { component: "The **WeekRenderer** component displays the **ISO-week number** corresponding to the input *value* of type `Date`. The displayed string is either pure or *spanned* i.e. enclosed by a *&lt;span&gt;&lt;/span&gt;* tag. Optionally, the year corresponding to the calculated week number can be disaplyed as well (e.g. '53/2026')." },
43+
},
44+
layout: 'centered'
45+
},
46+
render: RenderWeekRenderer
47+
} satisfies Meta<typeof WeekRenderer>;
48+
49+
export default meta;
50+
type Story = StoryObj<typeof meta>;
51+
52+
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
53+
54+
function RenderWeekRenderer(args) {
55+
if (typeof args.value == 'number') args.value = new Date(args.value);
56+
return <WeekRenderer key={Math.random()} {...args} />
57+
}
58+
59+
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
60+
61+
export const Playground = {
62+
name: "Playground",
63+
args: {
64+
value: testdate,
65+
pure: false,
66+
displayYear: false
67+
},
68+
tags: ['!dev']
69+
} satisfies Story;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AbstractRenderer, IAbstractRendererProps, ModifyValueType } from "../AbstractRenderer";
2+
3+
4+
export const defaultWeekRendererFormatOptions: Intl.NumberFormatOptions = { minimumIntegerDigits: 2, useGrouping: false };
5+
6+
interface WeekRendererValue {
7+
value: Date;
8+
}
9+
10+
type ISOWeek = {
11+
week: number;
12+
year: number;
13+
}
14+
15+
export interface IWeekRendererProps extends ModifyValueType<IAbstractRendererProps, WeekRendererValue> {
16+
minimumIntegerDigits?: 1 | 2;
17+
displayYear?: boolean;
18+
numberingSystem?: string;
19+
}
20+
21+
export class WeekRenderer extends AbstractRenderer<Date, IWeekRendererProps> {
22+
protected getFormatedText(): string {
23+
if (this.value) {
24+
25+
const options = {
26+
...defaultWeekRendererFormatOptions,
27+
... this.props.minimumIntegerDigits && { minimumIntegerDigits: this.props.minimumIntegerDigits },
28+
... this.props.numberingSystem && { numberingSystem: this.props.numberingSystem }
29+
};
30+
const isoWeek = calcISOWeek(this.value);
31+
const formatter = new Intl.NumberFormat(undefined, options);
32+
return formatter.format(isoWeek.week) + (this.props.displayYear ? "/" + formatter.format(isoWeek.year) : "");
33+
} else {
34+
return "";
35+
}
36+
}
37+
}
38+
39+
// Returns the ISO calendar week corresponding to the input date.
40+
export function calcISOWeek(inputDate: Date): ISOWeek {
41+
const purifiedDate = new Date(inputDate.getTime());
42+
// Remove Time part of the Date value
43+
purifiedDate.setHours(0, 0, 0, 0);
44+
// Thursday in current week decides the year.
45+
const thursday = purifiedDate;
46+
// This is the four-digit year corresponding to the input ISO week of the date.
47+
const year = calcYearOfISOCalendarWeek(thursday);
48+
// January 4 is always in week 1.
49+
const fourthOfJanuaryWeek = new Date(year, 0, 4);
50+
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
51+
const week = 1 + Math.round(((thursday.getTime() - fourthOfJanuaryWeek.getTime()) / 86400000 - 3 + (fourthOfJanuaryWeek.getDay() + 6) % 7) / 7);
52+
return { week, year }
53+
}
54+
55+
// Returns the four-digit year corresponding to the ISO week of the date.
56+
function calcYearOfISOCalendarWeek(inputDate: Date): number {
57+
const purifiedDate = new Date(inputDate.getTime());
58+
// Thursday in current week decides the year.
59+
purifiedDate.setDate(purifiedDate.getDate() + 3 - (purifiedDate.getDay() + 6) % 7);
60+
// console.log("4", purifiedDate);
61+
return purifiedDate.getFullYear();
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './WeekRenderer';

src/components/introduction.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Canvas, Source, Meta, Story } from '@storybook/blocks';
22
import { DateRenderer } from "./DateRenderer";
33
import { TimeRenderer } from "./TimeRenderer";
44
import { DateTimeRenderer } from "./DateTimeRenderer";
5+
import { WeekRenderer } from "./WeekRenderer";
56
import banner from '../../docs/johannes-plenio-2QUvkQTBh5s-unsplash.jpg';
67

78
<style>
@@ -153,6 +154,7 @@ import banner from '../../docs/johannes-plenio-2QUvkQTBh5s-unsplash.jpg';
153154
</section>
154155

155156
### DateTimeRenderer
157+
156158
<div className="previewoutput">
157159
<DateTimeRenderer value={new Date()} /><br />
158160
<DateTimeRenderer value={new Date()} locale="en-EN" pure /><br />
@@ -181,6 +183,42 @@ import banner from '../../docs/johannes-plenio-2QUvkQTBh5s-unsplash.jpg';
181183
<p>Note: the Date value passed as property to the DateTimeRenderer component should be a **UTC** date</p>
182184
</section>
183185

186+
### WeekRenderer
187+
188+
<div className="previewoutput">
189+
<DateRenderer value={new Date()} /> &rarr; <WeekRenderer value={new Date()} /><br />
190+
<DateRenderer value={new Date("2024-10-06T19:40:55.221Z")} /> &rarr; <WeekRenderer value={new Date("2024-10-06T19:40:55.221Z")} pure /><br />
191+
<DateRenderer value={new Date("2025-12-29T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2025-12-29T11:11:11.111Z")} displayYear pure /><br />
192+
<DateRenderer value={new Date("2024-12-30T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2024-12-30T11:11:11.111Z")} pure /><br />
193+
<DateRenderer value={new Date("2025-01-01T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2025-01-01T11:11:11.111Z")} displayYear pure /><br />
194+
<DateRenderer value={new Date("2025-01-01T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2025-01-01T11:11:11.111Z")} displayYear numberingSystem="tibt" pure /><br />
195+
<DateRenderer value={new Date("2026-12-28T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2026-12-28T11:11:11.111Z")} displayYear pure /><br />
196+
<DateRenderer value={new Date("2027-01-03T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2027-01-03T11:11:11.111Z")} displayYear pure /><br />
197+
<DateRenderer value={new Date("2027-01-04T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2027-01-04T11:11:11.111Z")} displayYear pure /><br />
198+
<DateRenderer value={new Date("2025-12-29T11:11:11.111Z")} /> &rarr; <WeekRenderer value={new Date("2025-12-29T11:11:11.111Z")} displayYear pure /><br />
199+
</div>
200+
<section className="accordion">
201+
<input type="checkbox" name="collapse2" id="preview4" tabIndex="0" />
202+
<div className="handle" >
203+
<label htmlFor="preview4">source code</label>
204+
</div>
205+
<div className="content">
206+
<Source code={`
207+
<WeekRenderer value={new Date()} />
208+
<WeekRenderer value={new Date("2024-10-06T19:40:55.221Z")} pure />
209+
<WeekRenderer value={new Date("2025-12-29T11:11:11.111Z")} displayYear pure />
210+
<WeekRenderer value={new Date("2024-12-30T11:11:11.111Z")} pure />
211+
<WeekRenderer value={new Date("2025-01-01T11:11:11.111Z")} displayYear pure />
212+
<WeekRenderer value={new Date("2025-01-01T11:11:11.111Z")} displayYear numberingSystem="tibt" pure />
213+
<WeekRenderer value={new Date("2026-12-28T11:11:11.111Z")} displayYear pure />
214+
<WeekRenderer value={new Date("2027-01-03T11:11:11.111Z")} displayYear pure />
215+
<WeekRenderer value={new Date("2027-01-04T11:11:11.111Z")} displayYear pure />
216+
<WeekRenderer value={new Date("2025-12-29T11:11:11.111Z")} displayYear pure />
217+
`} dark />
218+
</div>
219+
<p>Note: the Date value passed as property to the TimeRenderer component should be a **UTC** date</p>
220+
</section>
221+
184222
<hr />
185223

186224
<div className="right">Visit the [Github Repository](https://github.com/khatastroffik/react-text-renderer-components)</div>
@@ -194,4 +232,5 @@ https://github.com/storybookjs/storybook/discussions/16503
194232
https://storybook.js.org/addons/@storybook/addon-docs
195233
https://egghead.io/lessons/react-add-a-welcome-page-with-sequential-stories-to-a-react-storybook
196234
https://keithjgrant.com/posts/2023/04/transitioning-to-height-auto/
235+
https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
197236
*/}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from './components/dummy';
33
export * from './components/AbstractRenderer';
44
export * from './components/DateRenderer';
55
export * from './components/TimeRenderer';
6-
export * from './components/DateTimeRenderer';
6+
export * from './components/DateTimeRenderer';
7+
export * from './components/WeekRenderer';

0 commit comments

Comments
 (0)