Skip to content

Instantly share code, notes, and snippets.

@nyteshade
Last active December 2, 2023 18:17
Show Gist options
  • Save nyteshade/32a290ba99b40afe3b568478f0c47297 to your computer and use it in GitHub Desktop.
Save nyteshade/32a290ba99b40afe3b568478f0c47297 to your computer and use it in GitHub Desktop.
A class that can be used to track values over time.,

Historical Data Management Library

Overview

This library provides a generic Historical<T> class for tracking and managing the history of values in JavaScript and TypeScript applications. It is designed to be versatile, supporting undo/redo operations, callback registration for value changes, and serialization/deserialization functionalities. Additionally, the library offers integration with Angular applications through a dedicated service, HistoricalService, and an example Angular component, MyComponent, demonstrating its practical use.

Installation

(TODO: Add installation steps here once available)

Usage

Non-Angular Variant

Historical

The core of this library is the Historical<T> class, which is a generic class designed for tracking the history of a value of any type.

Basic Usage:
import { Historical } from './historical';

const historical = new Historical<number>(0);
historical.value = 1;
historical.value = 2;

console.log(historical.last); // Get the most recent historical value
Undo/Redo Operations:
historical.undo();
console.log(historical.value); // Outputs the previous value

historical.redo();
console.log(historical.value); // Redoes the last undone change
Serialization/Deserialization:
const serialized = historical.serialize();
const deserializedHistorical = Historical.deserialize<number>(serialized);
Registering Callbacks:
historical.registerCallback((newValue, oldValue, timestamp) => {
  console.log('Value changed from', oldValue, 'to', newValue, 'at', timestamp);
});

Angular Variant

HistoricalService

An Angular service that manages instances of Historical<T>.

Usage in Angular Component:
import { HistoricalService } from './historical.service';

// In your Angular component
constructor(private historicalService: HistoricalService) {}

ngOnInit() {
  const historical = this.historicalService.getHistorical('myValueKey', 0);
  // ...
}

MyComponent

An example Angular component demonstrating the usage of HistoricalService to manage historical data reactively.

Template:
<!-- Component template with data bindings and controls for undo/redo -->
Component Class:
import { Component, OnInit } from '@angular/core';
import { HistoricalService } from './historical.service';

@Component({
  selector: 'app-my-component',
  template: `<!-- Template Here -->`
})
export class MyComponent implements OnInit {
  // ...
}

Contributing

(TODO: Add contribution guidelines)

License

The code in this project is licensed under the MIT License.

MIT License

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

// Import RxJS dependencies
import { BehaviorSubject, Observable } from 'rxjs';
/**
* Determine the underlying type of the passed in value. This will often, but not
* always, have a 1:1 relationship with the value's constructor. `Undefined` and
* `Null` are obvious exceptions.
*
* @param value any value including null and undefined are acceptable here
* @returns the internal JSVM underlying type of the value. Note that in most cases
* this will refer to the supplied value's constructor. In cases of Null and
* Undefined, these types exist but are not exposed for use by the programmer in
* JavaScript.
*/
const typeOf = (value: any) => /(\w+)]/.exec(Object.prototype.toString.call(value))[1];
/**
* An array of constructor names, including 'Null', that are serializable via JSON
* without any additional code. This array allows the engineer to perform operations
* such as:
*
* ```
* if (jsonPrimitives.includes(typeOf(value))) {
* ...
* }
* ```
*
* @type String[]
*/
const jsonPrimitives = [String.name, Number.name, Array.name, Object.name, Boolean.name, 'Null'];
/**
* Options for configuring the behavior of the Historical class.
*/
export interface HistoricalOptions<T> {
/**
* The maximum number of historical values to store.
*/
howMany?: number;
/**
* Flag to enable or disable undo/redo functionality.
*/
enableUndoRedo?: boolean;
/**
* Optional handler for managing complex types during serialization and
* deserialization. If provided, allows custom processing of the Historical
* object's state.
*/
typeHandler?: HistoricalTypeHandler<T>;
}
/**
* Interface for handling complex types during serialization and deserialization.
* Allows custom processing of the Historical object's state.
*/
export interface HistoricalTypeHandler<T> {
/**
* Custom processing function for serialization.
*
* @param state - The current state of the Historical object.
* @returns The processed state ready for serialization.
*/
onSerialize(state: any): any;
/**
* Custom processing function for deserialization.
*
* @param state - The state to be deserialized.
* @returns The processed state ready to be used for creating a Historical object.
*/
onDeserialize(state: any): any;
}
/**
* Defines the signature for callback functions used in the Historical class.
* These callbacks are triggered whenever the tracked value changes.
*
* @param newValue - The new value of type T that has been set.
* @param oldValue - The previous value of type T before the change.
* @param timestamp - The timestamp (in milliseconds since UNIX epoch) when the
* change occurred.
*/
export type HistoricalCallback<T> = (newValue: T, oldValue: T, timestamp: number) => void;
/**
* The Historical class is a generic class for tracking the history of a value.
* It provides options to store historical values, implement undo/redo
* functionality, and register callbacks for value changes.
*
* @template T - The type of value being tracked.
*/
export class Historical<T> {
/**
* The current value of type T.
*/
private _value: T;
/**
* An array of objects storing the historical values along with their
* timestamps.
*/
private _lastValues: Array<{ value: T; when: number }> = [];
/**
* Timestamp of the last value update.
*/
private _when: number = Date.now();
/**
* The maximum number of historical values to store.
*/
private _howMany: number;
/**
* The options provided to this instance when it was created initially. These
* are consumed once again during serialization.
*/
public options: HistoricalOptions<T>;
/**
* An array of callback functions that are triggered whenever the value changes.
* Each callback function takes the new value, old value, and the timestamp
* of the change as arguments.
*/
private _callbacks: HistoricalCallback<T>[] = [];
/**
* Stack used to store previous values for the undo functionality.
*/
private _undoStack: T[] = [];
/**
* Stack used to store reverted values for the redo functionality.
*/
private _redoStack: T[] = [];
/**
* Flag to indicate whether undo/redo functionality is enabled.
*/
private _enableUndoRedo: boolean;
/**
* Optional handler for managing complex types during serialization and
* deserialization. If provided, allows custom processing of the Historical
* object's state.
*/
private _typeHandler?: HistoricalTypeHandler<T>;
/**
* A BehaviorSubject that emits the current value of the historical data each time
* it changes. This is used to provide an observable stream of value changes,
* which can be subscribed to in an Angular environment.
*/
private _valueChanges: BehaviorSubject<T>;
/**
* A BehaviorSubject that tracks the availability of the undo operation. It emits
* a boolean value indicating whether an undo action can be performed. This is
* useful for enabling or disabling UI elements related to undo functionality.
*/
private _undoAvailable: BehaviorSubject<boolean>;
/**
* A BehaviorSubject similar to _undoAvailable, but for the redo operation. It
* emits a boolean value indicating whether a redo action can be performed,
* aiding in the reactive enablement or disablement of redo-related UI elements.
*/
private _redoAvailable: BehaviorSubject<boolean>;
/**
* Initializes a new instance of the Historical class.
*
* @param value - The initial value of type T.
* @param options - Configuration options for the Historical instance. Includes
* the number of historical values to store (`howMany`) and a flag to enable or
* disable undo/redo functionality (`enableUndoRedo`), or a serialization /
* deserialization type handler for complex types
*/
constructor(
value: T,
options: HistoricalOptions<T> = {
howMany: 5,
enableUndoRedo: false,
typeHandler: null,
},
) {
this._value = value;
this._howMany = options.howMany || 5;
this._enableUndoRedo = options.enableUndoRedo || false;
this.options = options;
// Initialize RxJS BehaviorSubjects
this._valueChanges = new BehaviorSubject<T>(value);
this._undoAvailable = new BehaviorSubject<boolean>(false);
this._redoAvailable = new BehaviorSubject<boolean>(false);
}
/**
* Gets the current value.
*/
get value(): T {
return this._value;
}
/**
* Provides an Observable stream of the value changes. This getter allows
* subscribers to reactively listen to changes in the historical value. It is
* particularly useful in frameworks like Angular where components may need to
* update their state or UI in response to changes in the historical data.
*
* @returns An Observable that emits the current value each time it changes.
*/
get valueChanges(): Observable<T> {
return this._valueChanges.asObservable();
}
/**
* Sets the current value, updates the historical values, and manages undo/redo
* stacks if enabled. Triggers registered callbacks with the new value.
*
* @param newValue - The new value to set.
*/
set value(newValue: T) {
if (this._enableUndoRedo) {
this._undoStack.push(this._value);
this._redoStack = []; // Clear the redo stack on new change
}
const oldValue = this._value;
this._value = newValue;
this._when = Date.now();
this._lastValues.push({ value: oldValue, when: this._when });
if (this._lastValues.length > this._howMany) {
this._lastValues.shift();
}
// Trigger callbacks
this.triggerCallbacks(newValue, oldValue);
// Notify subscribers about the value change
this._valueChanges.next(this._value);
}
/**
* Gets the most recent historical value, or null if no history is present.
*/
get last(): T | null {
return this._lastValues.length ? this._lastValues[this._lastValues.length - 1].value : null;
}
/**
* Gets all the previous values along with their timestamps.
*/
get allPreviousValues(): Array<{ value: T; when: number }> {
return this._lastValues;
}
/**
* Gets the timestamp of the last value update.
*/
get when(): number {
return this._when;
}
/**
* Sets the value if it's different from the current value.
*
* @param newValue - The new value to consider.
* @returns A boolean indicating if the value was changed.
*/
setIfChanged(newValue: T): boolean {
if (newValue !== this._value) {
this.value = newValue;
return true;
}
return false;
}
/**
* Checks if the provided value is different from the current value.
*
* @param newValue - The value to compare against the current value.
* @returns A boolean indicating if the value is different.
*/
isDifferent(newValue: T): boolean {
return newValue !== this._value;
}
// MARK: - Event Listeners
/**
* Registers a callback to be executed on value change.
*
* @param callback - The callback function to register.
*/
registerCallback(callback: HistoricalCallback<T>): void {
this._callbacks.push(callback);
}
/**
* Deregisters a callback.
*
* @param callback - The callback function to deregister.
*/
deregisterCallback(callback: HistoricalCallback<T>): void {
this._callbacks = this._callbacks.filter(cb => cb !== callback);
}
/**
* Retrieves historical entries before a specified timestamp.
*
* @param timestamp - The cutoff timestamp.
* @returns An array of historical entries before the given timestamp.
*/
filterBefore(timestamp: number): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.when < timestamp);
}
/**
* Retrieves historical entries after a specified timestamp.
*
* @param timestamp - The cutoff timestamp.
* @returns An array of historical entries after the given timestamp.
*/
filterAfter(timestamp: number): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.when > timestamp);
}
// MARK: - Filtering and searching methods
/**
* Searches for historical entries with a specific value.
*
* @param value - The value to search for.
* @returns An array of historical entries matching the given value.
*/
findByValue(value: T): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.value === value);
}
/**
* Filters historical entries based on a custom predicate function.
*
* @param predicate - A function that takes an entry and returns a boolean.
* @returns An array of historical entries that satisfy the predicate.
*/
filterByPredicate(
predicate: (entry: { value: T; when: number }) => boolean,
): Array<{ value: T; when: number }> {
return this._lastValues.filter(predicate);
}
// MARK: - Undo/Redo functionality
/**
* Undoes the last change if undo/redo functionality is enabled. Reverts to the
* previous value and updates the redo stack. Triggers any necessary updates or
* callbacks related to the change.
*
* @returns A boolean indicating whether the undo operation was successful.
*/
undo(): boolean {
if (!this._enableUndoRedo || this._undoStack.length === 0) {
return false;
}
const oldValue = this._value;
const previousValue = this._undoStack.pop();
this._redoStack.push(oldValue);
this._value = previousValue;
this._when = Date.now();
this.triggerCallbacks(this._value, oldValue);
// Update observables
this._undoAvailable.next(this._undoStack.length > 0);
this._redoAvailable.next(this._redoStack.length > 0);
return true;
}
/**
* Redoes the last undone change if undo/redo functionality is enabled. Applies
* the next value in the redo stack and updates the undo stack. Triggers any
* necessary updates or callbacks related to the change.
*
* @returns A boolean indicating whether the redo operation was successful.
*/
redo(): boolean {
if (!this._enableUndoRedo || this._redoStack.length === 0) {
return false;
}
const oldValue = this._value;
const nextValue = this._redoStack.pop();
this._undoStack.push(oldValue);
this._value = nextValue;
this._when = Date.now();
this.triggerCallbacks(this._value, oldValue);
// Update observables
this._undoAvailable.next(this._undoStack.length > 0);
this._redoAvailable.next(this._redoStack.length > 0);
return true;
}
/**
* Provides an Observable stream indicating the availability of the undo operation.
* This getter allows subscribers, such as Angular components, to reactively listen
* to changes in the undo availability (e.g., for enabling/disabling an undo button
* in the UI).
*
* @returns An Observable that emits a boolean value indicating whether an undo
* operation is currently available.
*/
get undoAvailable(): Observable<boolean> {
return this._undoAvailable.asObservable();
}
/**
* Provides an Observable stream indicating the availability of the redo operation.
* Similar to `undoAvailable`, this getter enables reactive UI updates in response
* to changes in the redo operation's availability (e.g., updating the state of a
* redo button in the UI).
*
* @returns An Observable that emits a boolean value indicating whether a redo
* operation is currently available.
*/
get redoAvailable(): Observable<boolean> {
return this._redoAvailable.asObservable();
}
// MARK: - Private functions
/**
* Triggers the registered callbacks with the necessary information.
*
* @param newValue - The new value after the change.
* @param oldValue - The value before the change occurred.
*/
private triggerCallbacks(newValue: T, oldValue: T): void {
this._callbacks.forEach(callback => {
try {
callback(newValue, oldValue, this._when);
} catch (error) {
console.error('Error executing callback:', error);
}
});
}
// MARK: - Serialization
/**
* Serializes the current state of the Historical object into a JSON string.
*
* @returns A JSON string representing the current state of the object.
*/
serialize(): string {
let state = {
currentValue: this._value,
history: this._lastValues,
options: this.options,
};
// Check if the type of _value is a JSON serializable primitive
if (this._typeHandler) {
state = this._typeHandler.onSerialize(state);
} else if (!jsonPrimitives.includes(typeOf(this._value))) {
console.warn('Non-serializable type detected. Defaulting to null.');
state.currentValue = null;
}
return JSON.stringify(state);
}
// MARK: - Static values
/**
* Deserializes a JSON string into a Historical object.
*
* @param json - The JSON string to deserialize.
* @returns A new instance of Historical initialized with the deserialized data.
* @throws Will throw an error if the JSON string is invalid.
*/
static deserialize<T>(json: string, typeHandler?: HistoricalTypeHandler<T>): Historical<T> {
let data;
try {
data = JSON.parse(json);
} catch (error) {
throw new Error('Invalid JSON format for deserialization.');
}
const historical = new Historical(data.currentValue, data.options);
if (typeHandler) {
const processedData = typeHandler.onDeserialize(data);
historical._lastValues = processedData.history;
historical._value = processedData.currentValue;
} else {
historical._lastValues = data.history;
}
return historical;
}
/**
* Creates a new Historical instance from a given value.
*
* @param value - The initial value for the new Historical instance.
* @param howMany - The maximum number of historical values to store (default is 5).
* @returns A new instance of Historical.
*/
static from<T>(
value: T,
options: HistoricalOptions<T> = {
howMany: 5,
enableUndoRedo: false,
typeHandler: null,
},
): Historical<T> {
return new Historical(value, options);
}
}
import { Component, OnInit } from '@angular/core';
import { HistoricalService } from './historical.service';
/**
* Angular component that demonstrates the usage of HistoricalService to manage
* historical data. It subscribes to value changes and undo/redo availability to
* update the component's state and UI reactively.
*/
@Component({
selector: 'app-my-component',
template: `<!-- Component Template Here -->`
})
export class MyComponent implements OnInit {
/**
* The current value being managed by this component, which reflects the latest
* value from the Historical instance.
*/
myValue: number;
/**
* Injects the HistoricalService to access and manage historical data.
*
* @param historicalService - The service providing access to Historical instances.
*/
constructor(private historicalService: HistoricalService) {}
/**
* On initialization, the component subscribes to the Historical instance corresponding
* to 'myValue' for value changes and undo/redo availability. This allows the component
* to reactively update its state and UI in response to these changes.
*/
ngOnInit() {
const historical = this.historicalService.getHistorical('myValue', 0);
historical.valueChanges.subscribe(newValue => {
this.myValue = newValue;
// React to new value changes
});
// Example: setting a new value
historical.value = 123;
// Subscribing to undo/redo availability
historical.undoAvailable.subscribe(isUndoAvailable => {
// Enable or disable undo button based on isUndoAvailable
});
}
}
import { Injectable } from '@angular/core';
import { Historical, HistoricalOptions } from './Historical';
/**
* Service that manages instances of Historical class. This service acts as a centralized
* store and provides these instances to different components within an Angular application.
*/
@Injectable({
providedIn: 'root'
})
export class HistoricalService {
/**
* A map to store Historical instances, keyed by a unique string.
*/
private historicalInstances = new Map<string, Historical<any>>();
/**
* Retrieves an instance of Historical associated with the given key. If an instance
* does not already exist for the key, a new one is created with the provided default
* value and options.
*
* @param key - A unique string key to identify the Historical instance.
* @param defaultValue - The initial value for the Historical instance.
* @param options - Optional configuration options for the Historical instance.
* @returns An instance of Historical associated with the specified key.
*/
getHistorical<T>(key: string, defaultValue: T, options?: HistoricalOptions<T>): Historical<T> {
if (!this.historicalInstances.has(key)) {
this.historicalInstances.set(key, new Historical<T>(defaultValue, options));
}
return this.historicalInstances.get(key);
}
}
// Assuming Historical.ts contains the Historical class and related types/interfaces
import { Historical, HistoricalOptions, HistoricalTypeHandler } from './Historical';
// A complex type for testing
class ComplexType {
constructor(public id: number, public name: string) {}
}
// Custom handler for ComplexType
const complexTypeHandler: HistoricalTypeHandler<ComplexType> = {
onSerialize: (state) => {
return {
...state,
currentValue: state.currentValue ? `${state.currentValue.id}-${state.currentValue.name}` : null,
};
},
onDeserialize: (state) => {
if (state.currentValue) {
const [id, name] = state.currentValue.split('-');
return {
...state,
currentValue: new ComplexType(parseInt(id), name),
};
}
return state;
},
};
describe('Historical', () => {
describe('Basic Functionality', () => {
test('should initialize with a given value', () => {
const historical = new Historical<number>(0);
expect(historical.value).toBe(0);
});
test('should store and retrieve historical values', () => {
const historical = new Historical<number>(0);
historical.value = 1;
historical.value = 2;
expect(historical.allPreviousValues.map(entry => entry.value)).toEqual([0, 1]);
});
test('should not store more history than specified', () => {
const historical = new Historical<number>(0, { howMany: 2 });
historical.value = 1;
historical.value = 2;
historical.value = 3;
expect(historical.allPreviousValues.map(entry => entry.value)).toEqual([1, 2]);
});
test('should correctly identify value changes', () => {
const historical = new Historical<number>(0);
expect(historical.isDifferent(1)).toBeTruthy();
expect(historical.isDifferent(0)).toBeFalsy();
});
});
describe('Undo/Redo Functionality', () => {
let historical: Historical<number>;
beforeEach(() => {
historical = new Historical<number>(0, { enableUndoRedo: true });
});
test('should undo and redo changes', () => {
historical.value = 1;
historical.value = 2;
expect(historical.undo()).toBeTruthy();
expect(historical.value).toBe(1);
expect(historical.redo()).toBeTruthy();
expect(historical.value).toBe(2);
});
test('should handle undo and redo with empty stack', () => {
expect(historical.undo()).toBeFalsy();
expect(historical.redo()).toBeFalsy();
});
});
describe('Callback Functionality', () => {
test('should trigger callback on value change', () => {
const historical = new Historical<number>(0);
const mockCallback = jest.fn();
historical.registerCallback(mockCallback);
historical.value = 1;
expect(mockCallback).toHaveBeenCalledWith(1, 0, expect.any(Number));
});
});
describe('Serialization/Deserialization', () => {
test('should serialize and deserialize primitive type', () => {
const historical = new Historical<number>(1);
historical.value = 2;
const serialized = historical.serialize();
const deserialized = Historical.deserialize(serialized);
expect(deserialized.value).toBe(2);
});
test('should handle complex types with custom type handler', () => {
const complexValue = new ComplexType(1, 'test');
const historical = new Historical<ComplexType>(complexValue, {
typeHandler: complexTypeHandler,
});
historical.value = new ComplexType(2, 'test2');
const serialized = historical.serialize();
const deserialized = Historical.deserialize(serialized, complexTypeHandler);
expect(deserialized.value).toEqual(new ComplexType(2, 'test2'));
});
test('should warn when serializing non-primitive without handler', () => {
const historical = new Historical<ComplexType>(new ComplexType(1, 'test'));
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const serialized = historical.serialize();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Filtering and Searching', () => {
let historical: Historical<number>;
beforeEach(() => {
historical = new Historical<number>(0);
historical.value = 1;
historical.value = 2;
historical.value = 3;
});
test('should filter values before a timestamp', () => {
const entries = historical.filterBefore(Date.now());
expect(entries.length).toBeGreaterThanOrEqual(3);
expect(entries.map(entry => entry.value)).toEqual([0, 1, 2]);
});
test('should filter values after a timestamp', () => {
const pastTimestamp = Date.now() - 1000; // 1 second in the past
const entries = historical.filterAfter(pastTimestamp);
expect(entries.length).toBeGreaterThanOrEqual(3);
expect(entries.map(entry => entry.value)).toEqual([0, 1, 2]);
});
test('should find values by specific value', () => {
const entries = historical.findByValue(1);
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries[0].value).toBe(1);
});
test('should filter by custom predicate', () => {
const entries = historical.filterByPredicate(entry => entry.value > 1);
expect(entries.length).toBeGreaterThanOrEqual(2);
expect(entries.map(entry => entry.value)).toEqual([2, 3]);
});
});
describe('Additional Functionality', () => {
test('should create Historical from static method', () => {
const historical = Historical.from(0);
expect(historical.value).toBe(0);
});
test('should correctly indicate if value has changed', () => {
const historical = new Historical<number>(0);
const changed = historical.setIfChanged(1);
const unchanged = historical.setIfChanged(1);
expect(changed).toBeTruthy();
expect(unchanged).toBeFalsy();
});
});
});
/**
* Determine the underlying type of the passed in value. This will often, but not
* always, have a 1:1 relationship with the value's constructor. `Undefined` and
* `Null` are obvious exceptions.
*
* @param value any value including null and undefined are acceptable here
* @returns the internal JSVM underlying type of the value. Note that in most cases
* this will refer to the supplied value's constructor. In cases of Null and
* Undefined, these types exist but are not exposed for use by the programmer in
* JavaScript.
*/
const typeOf = (value: any) => /(\w+)]/.exec(Object.prototype.toString.call(value))[1];
/**
* An array of constructor names, including 'Null', that are serializable via JSON
* without any additional code. This array allows the engineer to perform operations
* such as:
*
* ```
* if (jsonPrimitives.includes(typeOf(value))) {
* ...
* }
* ```
*
* @type String[]
*/
const jsonPrimitives = [String.name, Number.name, Array.name, Object.name, Boolean.name, 'Null'];
/**
* Options for configuring the behavior of the Historical class.
*/
export interface HistoricalOptions<T> {
/**
* The maximum number of historical values to store.
*/
howMany?: number;
/**
* Flag to enable or disable undo/redo functionality.
*/
enableUndoRedo?: boolean;
/**
* Optional handler for managing complex types during serialization and
* deserialization. If provided, allows custom processing of the Historical
* object's state.
*/
typeHandler?: HistoricalTypeHandler<T>;
}
/**
* Interface for handling complex types during serialization and deserialization.
* Allows custom processing of the Historical object's state.
*/
export interface HistoricalTypeHandler<T> {
/**
* Custom processing function for serialization.
*
* @param state - The current state of the Historical object.
* @returns The processed state ready for serialization.
*/
onSerialize(state: any): any;
/**
* Custom processing function for deserialization.
*
* @param state - The state to be deserialized.
* @returns The processed state ready to be used for creating a Historical object.
*/
onDeserialize(state: any): any;
}
/**
* Defines the signature for callback functions used in the Historical class.
* These callbacks are triggered whenever the tracked value changes.
*
* @param newValue - The new value of type T that has been set.
* @param oldValue - The previous value of type T before the change.
* @param timestamp - The timestamp (in milliseconds since UNIX epoch) when the
* change occurred.
*/
export type HistoricalCallback<T> = (newValue: T, oldValue: T, timestamp: number) => void;
/**
* The Historical class is a generic class for tracking the history of a value.
* It provides options to store historical values, implement undo/redo
* functionality, and register callbacks for value changes.
*
* @template T - The type of value being tracked.
*/
export class Historical<T> {
/**
* The current value of type T.
*/
private _value: T;
/**
* An array of objects storing the historical values along with their
* timestamps.
*/
private _lastValues: Array<{ value: T; when: number }> = [];
/**
* Timestamp of the last value update.
*/
private _when: number = Date.now();
/**
* The maximum number of historical values to store.
*/
private _howMany: number;
/**
* The options provided to this instance when it was created initially. These
* are consumed once again during serialization.
*/
public options: HistoricalOptions<T>;
/**
* An array of callback functions that are triggered whenever the value changes.
* Each callback function takes the new value, old value, and the timestamp
* of the change as arguments.
*/
private _callbacks: HistoricalCallback<T>[] = [];
/**
* Stack used to store previous values for the undo functionality.
*/
private _undoStack: T[] = [];
/**
* Stack used to store reverted values for the redo functionality.
*/
private _redoStack: T[] = [];
/**
* Flag to indicate whether undo/redo functionality is enabled.
*/
private _enableUndoRedo: boolean;
/**
* Optional handler for managing complex types during serialization and
* deserialization. If provided, allows custom processing of the Historical
* object's state.
*/
private _typeHandler?: HistoricalTypeHandler<T>;
/**
* Initializes a new instance of the Historical class.
*
* @param value - The initial value of type T.
* @param options - Configuration options for the Historical instance. Includes
* the number of historical values to store (`howMany`) and a flag to enable or
* disable undo/redo functionality (`enableUndoRedo`), or a serialization /
* deserialization type handler for complex types
*/
constructor(
value: T,
options: HistoricalOptions<T> = {
howMany: 5,
enableUndoRedo: false,
typeHandler: null,
},
) {
this._value = value;
this._howMany = options.howMany || 5;
this._enableUndoRedo = options.enableUndoRedo || false;
this.options = options;
}
/**
* Gets the current value.
*/
get value(): T {
return this._value;
}
/**
* Sets the current value, updates the historical values, and manages undo/redo
* stacks if enabled. Triggers registered callbacks with the new value.
*
* @param newValue - The new value to set.
*/
set value(newValue: T) {
if (this._enableUndoRedo) {
this._undoStack.push(this._value);
this._redoStack = []; // Clear the redo stack on new change
}
const oldValue = this._value;
this._value = newValue;
this._when = Date.now();
this._lastValues.push({ value: oldValue, when: this._when });
if (this._lastValues.length > this._howMany) {
this._lastValues.shift();
}
// Trigger callbacks
this._callbacks.forEach(callback => {
try {
callback(newValue, oldValue, this._when);
} catch (error) {
console.error('Error executing callback:', error);
}
});
}
/**
* Gets the most recent historical value, or null if no history is present.
*/
get last(): T | null {
return this._lastValues.length ? this._lastValues[this._lastValues.length - 1].value : null;
}
/**
* Gets all the previous values along with their timestamps.
*/
get allPreviousValues(): Array<{ value: T; when: number }> {
return this._lastValues;
}
/**
* Gets the timestamp of the last value update.
*/
get when(): number {
return this._when;
}
/**
* Sets the value if it's different from the current value.
*
* @param newValue - The new value to consider.
* @returns A boolean indicating if the value was changed.
*/
setIfChanged(newValue: T): boolean {
if (newValue !== this._value) {
this.value = newValue;
return true;
}
return false;
}
/**
* Checks if the provided value is different from the current value.
*
* @param newValue - The value to compare against the current value.
* @returns A boolean indicating if the value is different.
*/
isDifferent(newValue: T): boolean {
return newValue !== this._value;
}
// MARK: - Event Listeners
/**
* Registers a callback to be executed on value change.
*
* @param callback - The callback function to register.
*/
registerCallback(callback: HistoricalCallback<T>): void {
this._callbacks.push(callback);
}
/**
* Deregisters a callback.
*
* @param callback - The callback function to deregister.
*/
deregisterCallback(callback: HistoricalCallback<T>): void {
this._callbacks = this._callbacks.filter(cb => cb !== callback);
}
/**
* Retrieves historical entries before a specified timestamp.
*
* @param timestamp - The cutoff timestamp.
* @returns An array of historical entries before the given timestamp.
*/
filterBefore(timestamp: number): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.when < timestamp);
}
/**
* Retrieves historical entries after a specified timestamp.
*
* @param timestamp - The cutoff timestamp.
* @returns An array of historical entries after the given timestamp.
*/
filterAfter(timestamp: number): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.when > timestamp);
}
// MARK: - Filtering and searching methods
/**
* Searches for historical entries with a specific value.
*
* @param value - The value to search for.
* @returns An array of historical entries matching the given value.
*/
findByValue(value: T): Array<{ value: T; when: number }> {
return this._lastValues.filter(entry => entry.value === value);
}
/**
* Filters historical entries based on a custom predicate function.
*
* @param predicate - A function that takes an entry and returns a boolean.
* @returns An array of historical entries that satisfy the predicate.
*/
filterByPredicate(
predicate: (entry: { value: T; when: number }) => boolean,
): Array<{ value: T; when: number }> {
return this._lastValues.filter(predicate);
}
// MARK: - Undo/Redo functionality
/**
* Undoes the last change if undo/redo functionality is enabled. Reverts to the
* previous value and updates the redo stack. Triggers any necessary updates or
* callbacks related to the change.
*
* @returns A boolean indicating whether the undo operation was successful.
*/
undo(): boolean {
if (!this._enableUndoRedo || this._undoStack.length === 0) {
return false;
}
const previousValue = this._undoStack.pop();
this._redoStack.push(this._value);
this._value = previousValue;
// Trigger any necessary updates or callbacks here
// ...
return true;
}
/**
* Redoes the last undone change if undo/redo functionality is enabled. Applies
* the next value in the redo stack and updates the undo stack. Triggers any
* necessary updates or callbacks related to the change.
*
* @returns A boolean indicating whether the redo operation was successful.
*/
redo(): boolean {
if (!this._enableUndoRedo || this._redoStack.length === 0) {
return false;
}
const nextValue = this._redoStack.pop();
this._undoStack.push(this._value);
this._value = nextValue;
// Trigger any necessary updates or callbacks here
// ...
return true;
}
// MARK: - Serialization
/**
* Serializes the current state of the Historical object into a JSON string.
*
* @returns A JSON string representing the current state of the object.
*/
serialize(): string {
let state = {
currentValue: this._value,
history: this._lastValues,
options: this.options,
};
// Check if the type of _value is a JSON serializable primitive
if (this._typeHandler) {
state = this._typeHandler.onSerialize(state);
} else if (!jsonPrimitives.includes(typeOf(this._value))) {
console.warn('Non-serializable type detected. Defaulting to null.');
state.currentValue = null;
}
return JSON.stringify(state);
}
/**
* Deserializes a JSON string into a Historical object.
*
* @param json - The JSON string to deserialize.
* @returns A new instance of Historical initialized with the deserialized data.
* @throws Will throw an error if the JSON string is invalid.
*/
static deserialize<T>(json: string, typeHandler?: HistoricalTypeHandler<T>): Historical<T> {
let data;
try {
data = JSON.parse(json);
} catch (error) {
throw new Error('Invalid JSON format for deserialization.');
}
const historical = new Historical(data.currentValue, data.options);
if (typeHandler) {
const processedData = typeHandler.onDeserialize(data);
historical._lastValues = processedData.history;
historical._value = processedData.currentValue;
} else {
historical._lastValues = data.history;
}
return historical;
}
// MARK: - Static values
/**
* Creates a new Historical instance from a given value.
*
* @param value - The initial value for the new Historical instance.
* @param howMany - The maximum number of historical values to store (default is 5).
* @returns A new instance of Historical.
*/
static from<T>(
value: T,
options: HistoricalOptions<T> = {
howMany: 5,
enableUndoRedo: false,
typeHandler: null,
},
): Historical<T> {
return new Historical(value, options);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment