///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
const lifecycleStages = [
"procured",
"in_service",
"maintenance",
"retired",
] as const;
type AssetStage = typeof lifecycleStages[number];
type StageCountMap = Record;
interface AssetInput {
id?: string;
name?: string;
owner?: string;
stage?: string;
}
interface AssetRecord {
id: string;
name: string;
owner: string;
stage: AssetStage;
}
interface LifecycleBucket {
stage: AssetStage;
label: string;
count: number;
assets: AssetSnapshot[];
}
interface AssetSnapshot {
id: string;
name: string;
owner: string;
stageLabel: string;
}
interface AssetLifecycleTrackerArgs {
assets: Default;
}
interface StageAdvanceEvent {
assetId?: string;
}
interface StageSetEvent {
assetId?: string;
}
interface TransitionEntry {
sequence: number;
assetId: string;
assetName: string;
from: AssetStage;
to: AssetStage;
message: string;
}
interface StageChangeSnapshot {
asset: AssetRecord;
from: AssetStage;
to: AssetStage;
}
const stageLabels: Record = {
procured: "Procured",
in_service: "In Service",
maintenance: "In Maintenance",
retired: "Retired",
};
const lifecycleStageSet = new Set(lifecycleStages);
const defaultAssets: AssetRecord[] = [
{
id: "SRV-001",
name: "Build Server",
owner: "Infrastructure",
stage: "procured",
},
{
id: "LPT-104",
name: "Design Laptop",
owner: "Design",
stage: "in_service",
},
{
id: "PRJ-205",
name: "Projector Kit",
owner: "Facilities",
stage: "maintenance",
},
];
const cloneAssets = (entries: readonly AssetRecord[]): AssetRecord[] =>
entries.map((entry) => ({ ...entry }));
const sanitizeAssetId = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim().toUpperCase();
return trimmed ? trimmed : null;
};
const sanitizeAssetName = (value: unknown, fallback: string): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed ? trimmed : fallback;
};
const sanitizeOwner = (value: unknown): string => {
if (typeof value !== "string") return "General";
const trimmed = value.trim();
return trimmed ? trimmed : "General";
};
const sanitizeStage = (value: unknown): AssetStage => {
if (typeof value !== "string") return "procured";
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (lifecycleStageSet.has(normalized as AssetStage)) {
return normalized as AssetStage;
}
return "procured";
};
const sanitizeAsset = (value: unknown): AssetRecord | null => {
const candidate = value as AssetInput | undefined;
const id = sanitizeAssetId(candidate?.id);
if (!id) return null;
const stage = sanitizeStage(candidate?.stage);
const name = sanitizeAssetName(candidate?.name, `Asset ${id}`);
const owner = sanitizeOwner(candidate?.owner);
return { id, stage, name, owner };
};
const compareAssets = (left: AssetRecord, right: AssetRecord): number => {
const leftIndex = lifecycleStages.indexOf(left.stage);
const rightIndex = lifecycleStages.indexOf(right.stage);
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
const nameCompare = left.name.localeCompare(right.name);
if (nameCompare !== 0) return nameCompare;
return left.id.localeCompare(right.id);
};
const sanitizeAssetList = (value: unknown): AssetRecord[] => {
if (!Array.isArray(value)) {
return cloneAssets(defaultAssets);
}
const seen = new Set();
const sanitized: AssetRecord[] = [];
for (const raw of value) {
const asset = sanitizeAsset(raw);
if (!asset) continue;
if (seen.has(asset.id)) continue;
seen.add(asset.id);
sanitized.push(asset);
}
if (sanitized.length === 0) {
return cloneAssets(defaultAssets);
}
sanitized.sort(compareAssets);
return sanitized;
};
const createEmptyCounts = (): StageCountMap => ({
procured: 0,
in_service: 0,
maintenance: 0,
retired: 0,
});
const nextStage = (stage: AssetStage): AssetStage | null => {
const index = lifecycleStages.indexOf(stage);
const next = lifecycleStages[index + 1];
return next ?? null;
};
const toAssetInputs = (entries: readonly AssetRecord[]): AssetInput[] =>
entries.map((entry) => ({ ...entry }));
const applyStageChange = (
entries: AssetRecord[],
id: string,
resolve: (asset: AssetRecord) => AssetStage | null,
): { list: AssetRecord[]; change: StageChangeSnapshot | null } => {
const updated: AssetRecord[] = [];
let change: StageChangeSnapshot | null = null;
for (const asset of entries) {
if (asset.id !== id) {
updated.push(asset);
continue;
}
const next = resolve(asset);
if (!next || next === asset.stage) {
updated.push(asset);
continue;
}
const mutated = { ...asset, stage: next };
change = { asset: mutated, from: asset.stage, to: next };
updated.push(mutated);
}
if (!change) return { list: entries, change: null };
return { list: sanitizeAssetList(updated), change };
};
const recordTransition = (
history: Cell,
asset: AssetRecord,
from: AssetStage,
to: AssetStage,
) => {
if (from === to) return;
const entries = history.get();
const log = Array.isArray(entries) ? [...entries] : [];
const message = `${asset.name} moved from ${stageLabels[from]} to ${
stageLabels[to]
}`;
const entry: TransitionEntry = {
sequence: log.length + 1,
assetId: asset.id,
assetName: asset.name,
from,
to,
message,
};
log.push(entry);
history.set(log);
};
const advanceLifecycle = handler(
(
event: StageAdvanceEvent | undefined,
context: { assets: Cell; history: Cell },
) => {
const id = sanitizeAssetId(event?.assetId);
if (!id) return;
const current = sanitizeAssetList(context.assets.get());
const result = applyStageChange(
current,
id,
(asset) => nextStage(asset.stage),
);
if (!result.change) return;
context.assets.set(toAssetInputs(result.list));
recordTransition(
context.history,
result.change.asset,
result.change.from,
result.change.to,
);
},
);
const markMaintenance = handler(
(
event: StageSetEvent | undefined,
context: { assets: Cell; history: Cell },
) => {
const id = sanitizeAssetId(event?.assetId);
if (!id) return;
const current = sanitizeAssetList(context.assets.get());
const result = applyStageChange(current, id, () => "maintenance");
if (!result.change) return;
context.assets.set(toAssetInputs(result.list));
recordTransition(
context.history,
result.change.asset,
result.change.from,
result.change.to,
);
},
);
const retireAsset = handler(
(
event: StageSetEvent | undefined,
context: { assets: Cell; history: Cell },
) => {
const id = sanitizeAssetId(event?.assetId);
if (!id) return;
const current = sanitizeAssetList(context.assets.get());
const result = applyStageChange(current, id, () => "retired");
if (!result.change) return;
context.assets.set(toAssetInputs(result.list));
recordTransition(
context.history,
result.change.asset,
result.change.from,
result.change.to,
);
},
);
const restoreAsset = handler(
(
event: StageSetEvent | undefined,
context: { assets: Cell; history: Cell },
) => {
const id = sanitizeAssetId(event?.assetId);
if (!id) return;
const current = sanitizeAssetList(context.assets.get());
const result = applyStageChange(current, id, () => "in_service");
if (!result.change) return;
context.assets.set(toAssetInputs(result.list));
recordTransition(
context.history,
result.change.asset,
result.change.from,
result.change.to,
);
},
);
export const assetLifecycleTracker = recipe(
"Asset Lifecycle Tracker",
({ assets }) => {
const transitionLog = cell([]);
const assetsView = lift(sanitizeAssetList)(assets);
const stageBuckets = lift((entries: AssetRecord[]): LifecycleBucket[] => {
const buckets = lifecycleStages.map((stage) => ({
stage,
label: stageLabels[stage],
count: 0,
assets: [] as AssetSnapshot[],
}));
const lookup = new Map();
for (const bucket of buckets) lookup.set(bucket.stage, bucket);
for (const asset of entries) {
const bucket = lookup.get(asset.stage);
if (!bucket) continue;
bucket.count += 1;
bucket.assets.push({
id: asset.id,
name: asset.name,
owner: asset.owner,
stageLabel: stageLabels[asset.stage],
});
}
return buckets;
})(assetsView);
const stageCounts = lift((entries: AssetRecord[]): StageCountMap => {
const counts = createEmptyCounts();
for (const asset of entries) {
counts[asset.stage] += 1;
}
return counts;
})(assetsView);
const totalAssets = lift((counts: StageCountMap) =>
lifecycleStages.reduce((sum, stage) => sum + counts[stage], 0)
)(stageCounts);
const activeCount = lift((counts: StageCountMap) =>
counts.procured + counts.in_service + counts.maintenance
)(stageCounts);
const lifecycleProgress = lift(
(input: { active: number; total: number }) => {
if (input.total === 0) return 0;
return Math.round((input.active / input.total) * 100);
},
)({ active: activeCount, total: totalAssets });
const lifecycleLabel = str`${activeCount} active of ${totalAssets} assets`;
const activeAssetIds = lift((entries: AssetRecord[]) =>
entries.filter((asset) => asset.stage !== "retired").map((asset) =>
asset.id
)
)(assetsView);
const transitionHistory = lift((entries: TransitionEntry[]) =>
entries.map((entry) => ({
sequence: entry.sequence,
assetId: entry.assetId,
from: stageLabels[entry.from],
to: stageLabels[entry.to],
message: entry.message,
}))
)(transitionLog);
const transitionMessages = lift((entries: TransitionEntry[]) =>
entries.map((entry) => entry.message)
)(transitionLog);
const busiestStage = lift((buckets: LifecycleBucket[]) => {
let current: { label: string; count: number } = {
label: "Procured",
count: 0,
};
for (const bucket of buckets) {
if (bucket.count > current.count) {
current = { label: bucket.label, count: bucket.count };
}
}
return current;
})(stageBuckets);
return {
assets,
assetsView,
stageBuckets,
stageCounts,
activeAssetIds,
lifecycleLabel,
lifecycleProgress,
transitionHistory,
transitionMessages,
busiestStage,
advanceLifecycle: advanceLifecycle({ assets, history: transitionLog }),
markMaintenance: markMaintenance({ assets, history: transitionLog }),
retireAsset: retireAsset({ assets, history: transitionLog }),
restoreAsset: restoreAsset({ assets, history: transitionLog }),
};
},
);
export type {
AssetInput,
AssetLifecycleTrackerArgs,
AssetRecord,
AssetSnapshot,
AssetStage,
LifecycleBucket,
StageCountMap,
TransitionEntry,
};