Timeline: draw stage-period ranges from activities, milestones on top
Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
This commit is contained in:
@@ -10,7 +10,7 @@ import type {
|
|||||||
StageSectionConfig,
|
StageSectionConfig,
|
||||||
} from "@/types/form";
|
} from "@/types/form";
|
||||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
import { STAGE_RANK, buildStageRankAtDate } from "@/lib/stageRank";
|
import { STAGE_RANK } from "@/lib/stageRank";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
import {
|
import {
|
||||||
FieldHistoryGroup,
|
FieldHistoryGroup,
|
||||||
@@ -121,7 +121,7 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
|
|||||||
<DateTimeline
|
<DateTimeline
|
||||||
sections={config.sections}
|
sections={config.sections}
|
||||||
fieldHistory={data.fieldHistory}
|
fieldHistory={data.fieldHistory}
|
||||||
stageRankAtDate={buildStageRankAtDate(data.activities)}
|
activities={data.activities}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sectionsToRender.length === 0 ? (
|
{sectionsToRender.length === 0 ? (
|
||||||
|
|||||||
@@ -757,7 +757,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
|
|||||||
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
||||||
];
|
];
|
||||||
|
|
||||||
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
|
return (
|
||||||
|
<DateTimeline
|
||||||
|
sections={sections}
|
||||||
|
fieldHistory={fieldHistory}
|
||||||
|
activities={data.activities}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatLongDate retained for symmetry with other report views; unused here
|
// formatLongDate retained for symmetry with other report views; unused here
|
||||||
|
|||||||
@@ -1,41 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
|
import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form";
|
||||||
import { formatShortDate } from "./FieldHistory";
|
import { formatShortDate } from "./FieldHistory";
|
||||||
|
import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date-grouped timeline strip at the top of the report. Walks every
|
* Date-grouped timeline strip at the top of the report. Renders five
|
||||||
* date-type field across all stage sections, pulls each field's
|
* horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom)
|
||||||
* most-recent entered date, and plots events into five horizontal swim
|
* and overlays two kinds of events:
|
||||||
* lanes — one per stage, Stage 5 on top down to Stage 1 at the bottom.
|
|
||||||
*
|
*
|
||||||
* Each dot is placed on the lane corresponding to the stage the co-op
|
* 1. Stage-period ranges, one per stage-transition activity, drawn
|
||||||
* was in on the field's stored date, resolved via `stageRankAtDate`
|
* as a soft bar from the activity date to the date of the next
|
||||||
* over the activity stage-transition history. When the date precedes
|
* activity that moved the co-op to a higher stage. The latest
|
||||||
* any known transition (no evidence), we fall back to the field's own
|
* open stage extends to today.
|
||||||
* section rank so the dot still appears somewhere sensible. Stage 5's
|
* 2. Milestone dots from date-type custom fields. The dot is placed
|
||||||
* `Date_Opened` is forced to Stage 5 and gets visual emphasis as the
|
* on the lane corresponding to the stage the co-op was in on the
|
||||||
* journey's anchor at the right end of the time axis. A faint "Today"
|
* field's stored date (resolved from the activity stream). When
|
||||||
* tick anchors the reader if today falls within the range, and an
|
* that's unknowable (date precedes any transition), it falls back
|
||||||
* adaptive month/year axis runs beneath the lanes. Each dot exposes a
|
* to the field's own section rank. Stage 5's `Date_Opened` is
|
||||||
* tooltip on hover or keyboard focus.
|
* pinned to Stage 5 as the journey anchor.
|
||||||
|
*
|
||||||
|
* A faint "Today" tick anchors the reader if today falls within the
|
||||||
|
* range, and an adaptive month/year axis runs beneath the lanes. Each
|
||||||
|
* dot exposes a tooltip on hover or keyboard focus.
|
||||||
*/
|
*/
|
||||||
export function DateTimeline({
|
export function DateTimeline({
|
||||||
sections,
|
sections,
|
||||||
fieldHistory,
|
fieldHistory,
|
||||||
stageRankAtDate,
|
activities,
|
||||||
}: {
|
}: {
|
||||||
sections: StageSectionConfig[];
|
sections: StageSectionConfig[];
|
||||||
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
||||||
stageRankAtDate?: (isoDate: string) => number | null;
|
activities: ActivitySummary[];
|
||||||
}) {
|
}) {
|
||||||
type Event = {
|
const stageRankAtDate = buildStageRankAtDate(activities);
|
||||||
|
const ranges = computeStageRanges(activities);
|
||||||
|
|
||||||
|
type MilestoneEvent = {
|
||||||
rank: number;
|
rank: number;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
date: string; // YYYY-MM-DD or full ISO
|
date: string;
|
||||||
isOpened: boolean;
|
isOpened: boolean;
|
||||||
};
|
};
|
||||||
const events: Event[] = [];
|
const milestones: MilestoneEvent[] = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
for (const f of section.fields) {
|
for (const f of section.fields) {
|
||||||
if (f.type !== "date") continue;
|
if (f.type !== "date") continue;
|
||||||
@@ -44,39 +51,56 @@ export function DateTimeline({
|
|||||||
const v = history[0].value;
|
const v = history[0].value;
|
||||||
if (typeof v !== "string" || v.length === 0) continue;
|
if (typeof v !== "string" || v.length === 0) continue;
|
||||||
const isOpened = f.name === "Date_Opened";
|
const isOpened = f.name === "Date_Opened";
|
||||||
const resolved = stageRankAtDate?.(v) ?? null;
|
const resolved = stageRankAtDate(v);
|
||||||
// Date_Opened is the Stage-5 anchor by definition. Otherwise prefer
|
|
||||||
// the stage in effect on the event date; fall back to the field's
|
|
||||||
// section rank when no transition history covers that point.
|
|
||||||
let rank = isOpened ? 5 : (resolved ?? section.rank);
|
let rank = isOpened ? 5 : (resolved ?? section.rank);
|
||||||
if (rank < 1 || rank > 5) rank = section.rank;
|
if (rank < 1 || rank > 5) rank = section.rank;
|
||||||
if (rank < 1 || rank > 5) continue;
|
if (rank < 1 || rank > 5) continue;
|
||||||
events.push({
|
milestones.push({ rank, fieldLabel: f.label, date: v, isOpened });
|
||||||
rank,
|
|
||||||
fieldLabel: f.label,
|
|
||||||
date: v,
|
|
||||||
isOpened,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (events.length === 0) return null;
|
|
||||||
|
|
||||||
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
|
// Collect every time value that should influence the time axis: range
|
||||||
if (times.length === 0) return null;
|
// starts, range ends (excluding open-ended), milestone dates, and today
|
||||||
|
// if any range is open (so the open range visibly extends to "now").
|
||||||
const openedEvent = events.find((e) => e.isOpened);
|
const timeBag: number[] = [];
|
||||||
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
|
let hasOpenRange = false;
|
||||||
const minT = Math.min(...times);
|
for (const r of ranges) {
|
||||||
// If Date_Opened is set, anchor the right edge there; if any other event
|
const ts = new Date(r.startDate).getTime();
|
||||||
// is later, extend so nothing falls off the chart.
|
if (Number.isFinite(ts)) timeBag.push(ts);
|
||||||
const maxT = Math.max(...times, openedTime ?? -Infinity);
|
if (r.endDate === null) {
|
||||||
const tRange = maxT - minT || 1;
|
hasOpenRange = true;
|
||||||
|
} else {
|
||||||
|
const te = new Date(r.endDate).getTime();
|
||||||
|
if (Number.isFinite(te)) timeBag.push(te);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of milestones) {
|
||||||
|
const t = new Date(m.date).getTime();
|
||||||
|
if (Number.isFinite(t)) timeBag.push(t);
|
||||||
|
}
|
||||||
const todayT = Date.now();
|
const todayT = Date.now();
|
||||||
|
if (hasOpenRange) timeBag.push(todayT);
|
||||||
|
if (timeBag.length === 0) return null;
|
||||||
|
|
||||||
|
let minT = Math.min(...timeBag);
|
||||||
|
let maxT = Math.max(...timeBag);
|
||||||
|
if (minT === maxT) {
|
||||||
|
const pad = 30 * 24 * 3600 * 1000;
|
||||||
|
minT -= pad;
|
||||||
|
maxT += pad;
|
||||||
|
}
|
||||||
|
const tRange = maxT - minT || 1;
|
||||||
const todayPct =
|
const todayPct =
|
||||||
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
|
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
|
||||||
|
|
||||||
const ticks = generateAxisTicks(minT, maxT);
|
const ticks = generateAxisTicks(minT, maxT);
|
||||||
|
|
||||||
|
const rangesByRank = new Map<number, StageRange[]>();
|
||||||
|
for (const r of ranges) {
|
||||||
|
const list = rangesByRank.get(r.rank) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
rangesByRank.set(r.rank, list);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby="timeline-heading"
|
aria-labelledby="timeline-heading"
|
||||||
@@ -97,8 +121,9 @@ export function DateTimeline({
|
|||||||
</div>
|
</div>
|
||||||
<ol className="mt-4 space-y-2">
|
<ol className="mt-4 space-y-2">
|
||||||
{[5, 4, 3, 2, 1].map((rank) => {
|
{[5, 4, 3, 2, 1].map((rank) => {
|
||||||
const rowEvents = events.filter((e) => e.rank === rank);
|
const rowRanges = rangesByRank.get(rank) ?? [];
|
||||||
const isEmpty = rowEvents.length === 0;
|
const rowMilestones = milestones.filter((e) => e.rank === rank);
|
||||||
|
const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={rank}
|
key={rank}
|
||||||
@@ -115,6 +140,30 @@ export function DateTimeline({
|
|||||||
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Stage-period ranges: soft bar from activity start to next
|
||||||
|
higher-stage activity (or to today if still open). */}
|
||||||
|
{rowRanges.map((r, i) => {
|
||||||
|
const ts = new Date(r.startDate).getTime();
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
const endT =
|
||||||
|
r.endDate === null
|
||||||
|
? Math.max(todayT, ts)
|
||||||
|
: new Date(r.endDate).getTime();
|
||||||
|
if (!Number.isFinite(endT)) return null;
|
||||||
|
const startPct = ((ts - minT) / tRange) * 100;
|
||||||
|
const endPct = ((endT - minT) / tRange) * 100;
|
||||||
|
const widthPct = Math.max(endPct - startPct, 0.5);
|
||||||
|
return (
|
||||||
|
<StageRangeBar
|
||||||
|
key={`r-${i}`}
|
||||||
|
rank={rank}
|
||||||
|
range={r}
|
||||||
|
startPct={startPct}
|
||||||
|
widthPct={widthPct}
|
||||||
|
isOpen={r.endDate === null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{todayPct !== null && (
|
{todayPct !== null && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -122,19 +171,23 @@ export function DateTimeline({
|
|||||||
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rowEvents.map((e, i) => {
|
{/* Activity start dots — placed at each range start for
|
||||||
const t = new Date(e.date).getTime();
|
keyboard focus + tooltip with the activity subject. */}
|
||||||
|
{rowRanges.map((r, i) => {
|
||||||
|
const t = new Date(r.startDate).getTime();
|
||||||
if (!Number.isFinite(t)) return null;
|
if (!Number.isFinite(t)) return null;
|
||||||
const x = ((t - minT) / tRange) * 100;
|
const x = ((t - minT) / tRange) * 100;
|
||||||
return (
|
return (
|
||||||
<TimelineDot
|
<ActivityDot key={`a-${i}`} rank={rank} range={r} x={x} />
|
||||||
key={i}
|
|
||||||
rank={rank}
|
|
||||||
event={e}
|
|
||||||
x={x}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Milestone date-field dots layered on top. */}
|
||||||
|
{rowMilestones.map((e, i) => {
|
||||||
|
const t = new Date(e.date).getTime();
|
||||||
|
if (!Number.isFinite(t)) return null;
|
||||||
|
const x = ((t - minT) / tRange) * 100;
|
||||||
|
return <MilestoneDot key={`m-${i}`} rank={rank} event={e} x={x} />;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -154,7 +207,6 @@ export function DateTimeline({
|
|||||||
/>
|
/>
|
||||||
{ticks.map((tk, i) => {
|
{ticks.map((tk, i) => {
|
||||||
const x = ((tk.t - minT) / tRange) * 100;
|
const x = ((tk.t - minT) / tRange) * 100;
|
||||||
// Edge labels shift so they don't overflow the row.
|
|
||||||
const alignClass =
|
const alignClass =
|
||||||
x < 6
|
x < 6
|
||||||
? "left-0 origin-top-left"
|
? "left-0 origin-top-left"
|
||||||
@@ -201,8 +253,14 @@ export function DateTimeline({
|
|||||||
)}
|
)}
|
||||||
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
||||||
<ul className="sr-only">
|
<ul className="sr-only">
|
||||||
{events.map((e, i) => (
|
{ranges.map((r, i) => (
|
||||||
<li key={i}>
|
<li key={`r-${i}`}>
|
||||||
|
Stage {r.rank}: {formatShortDate(r.startDate)}
|
||||||
|
{r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{milestones.map((e, i) => (
|
||||||
|
<li key={`m-${i}`}>
|
||||||
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
||||||
{e.isOpened ? " (opened)" : ""}
|
{e.isOpened ? " (opened)" : ""}
|
||||||
</li>
|
</li>
|
||||||
@@ -214,8 +272,6 @@ export function DateTimeline({
|
|||||||
|
|
||||||
function stageDotBg(rank: number): string {
|
function stageDotBg(rank: number): string {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 0:
|
|
||||||
return "bg-leaf-200";
|
|
||||||
case 1:
|
case 1:
|
||||||
return "bg-leaf-300";
|
return "bg-leaf-300";
|
||||||
case 2:
|
case 2:
|
||||||
@@ -231,14 +287,108 @@ function stageDotBg(rank: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageRangeBg(rank: number): string {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return "bg-leaf-300/30";
|
||||||
|
case 2:
|
||||||
|
return "bg-leaf-500/25";
|
||||||
|
case 3:
|
||||||
|
return "bg-leaf-600/25";
|
||||||
|
case 4:
|
||||||
|
return "bg-leaf-700/25";
|
||||||
|
case 5:
|
||||||
|
return "bg-clay-700/25";
|
||||||
|
default:
|
||||||
|
return "bg-leaf-500/25";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft horizontal bar marking a stage period on a lane. */
|
||||||
|
function StageRangeBar({
|
||||||
|
rank,
|
||||||
|
range,
|
||||||
|
startPct,
|
||||||
|
widthPct,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
rank: number;
|
||||||
|
range: StageRange;
|
||||||
|
startPct: number;
|
||||||
|
widthPct: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
title={
|
||||||
|
`Stage ${rank}: ${formatShortDate(range.startDate)}` +
|
||||||
|
(range.endDate ? ` → ${formatShortDate(range.endDate)}` : " → current")
|
||||||
|
}
|
||||||
|
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
|
||||||
|
className={
|
||||||
|
"absolute top-1/2 -translate-y-1/2 h-2 rounded-full " +
|
||||||
|
stageRangeBg(rank) +
|
||||||
|
(isOpen ? " ring-1 ring-inset ring-clay-300/40" : "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dot at the start of a stage period; carries the activity tooltip. */
|
||||||
|
function ActivityDot({
|
||||||
|
rank,
|
||||||
|
range,
|
||||||
|
x,
|
||||||
|
}: {
|
||||||
|
rank: number;
|
||||||
|
range: StageRange;
|
||||||
|
x: number;
|
||||||
|
}) {
|
||||||
|
const tipAlign =
|
||||||
|
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Stage ${rank} check-in, ${formatShortDate(range.startDate)}`}
|
||||||
|
style={{ left: `${x}%` }}
|
||||||
|
className={
|
||||||
|
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 ring-1 ring-paper h-3 w-3 " +
|
||||||
|
stageDotBg(rank)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className={
|
||||||
|
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
|
||||||
|
tipAlign
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="block font-medium">
|
||||||
|
{range.subject ?? `Stage ${rank} check-in`}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block tabular-nums text-paper-2/80">
|
||||||
|
{formatShortDate(range.startDate)}
|
||||||
|
{range.endDate ? (
|
||||||
|
<> → {formatShortDate(range.endDate)}</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1.5 text-paper-2/60">→ current</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single dot on the timeline plus its hover/focus tooltip. Keyboard
|
* A single milestone date-field dot plus its hover/focus tooltip. Keyboard
|
||||||
* users can Tab to each dot and the tooltip will appear via
|
* users can Tab to each dot and the tooltip will appear via
|
||||||
* `group-focus-within`. The tooltip is anchored above the dot; for dots
|
* `group-focus-within`. The tooltip is anchored above the dot; for dots
|
||||||
* near the left or right edge of the row, anchor flips so the card
|
* near the left or right edge of the row, anchor flips so the card
|
||||||
* doesn't overflow.
|
* doesn't overflow.
|
||||||
*/
|
*/
|
||||||
function TimelineDot({
|
function MilestoneDot({
|
||||||
rank,
|
rank,
|
||||||
event,
|
event,
|
||||||
x,
|
x,
|
||||||
@@ -252,11 +402,7 @@ function TimelineDot({
|
|||||||
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
||||||
const ring = isOpened ? "ring-2" : "ring-1";
|
const ring = isOpened ? "ring-2" : "ring-1";
|
||||||
const tipAlign =
|
const tipAlign =
|
||||||
x < 18
|
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
? "left-0"
|
|
||||||
: x > 82
|
|
||||||
? "right-0"
|
|
||||||
: "left-1/2 -translate-x-1/2";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
@@ -319,7 +465,6 @@ export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number
|
|||||||
plan.unit === "year"
|
plan.unit === "year"
|
||||||
? 0
|
? 0
|
||||||
: Math.floor(start.getMonth() / plan.step) * plan.step;
|
: Math.floor(start.getMonth() / plan.step) * plan.step;
|
||||||
// Don't skip a tick that sits right at minT — start from minT-aligned step.
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const t = new Date(y, m, 1).getTime();
|
const t = new Date(y, m, 1).getTime();
|
||||||
if (t > maxT) break;
|
if (t > maxT) break;
|
||||||
|
|||||||
@@ -48,3 +48,74 @@ export function buildStageRankAtDate(
|
|||||||
return last;
|
return last;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StageRange {
|
||||||
|
rank: number;
|
||||||
|
/** ISO date this stage period begins (the stage-transition activity). */
|
||||||
|
startDate: string;
|
||||||
|
/**
|
||||||
|
* ISO date the period ends — the next chronologically-later activity with
|
||||||
|
* a higher stage rank. `null` means the period is open-ended (still in
|
||||||
|
* effect today); the chart should extend it to the right edge.
|
||||||
|
*/
|
||||||
|
endDate: string | null;
|
||||||
|
activityId: number;
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive per-activity stage ranges from the activity stream. Each
|
||||||
|
* stage-transition activity (one with a non-empty `stage`) produces one
|
||||||
|
* range: start = the activity's date, end = the date of the next
|
||||||
|
* chronologically-following activity whose stage rank is *strictly higher*.
|
||||||
|
* If no later activity has a higher rank, the range is open-ended.
|
||||||
|
*
|
||||||
|
* Multiple activities at the same stage each yield their own range; they'll
|
||||||
|
* share the same end date if they sit between the same two higher-rank
|
||||||
|
* boundaries.
|
||||||
|
*/
|
||||||
|
export function computeStageRanges(activities: ActivitySummary[]): StageRange[] {
|
||||||
|
type Item = {
|
||||||
|
t: number;
|
||||||
|
rank: number;
|
||||||
|
activityId: number;
|
||||||
|
isoDate: string;
|
||||||
|
subject: string | null;
|
||||||
|
};
|
||||||
|
const items: Item[] = [];
|
||||||
|
for (const a of activities) {
|
||||||
|
if (typeof a.stage !== "string" || a.stage.length === 0) continue;
|
||||||
|
const r = STAGE_RANK[a.stage];
|
||||||
|
if (typeof r !== "number" || r < 1 || r > 5) continue;
|
||||||
|
const t = new Date(a.date).getTime();
|
||||||
|
if (!Number.isFinite(t)) continue;
|
||||||
|
items.push({
|
||||||
|
t,
|
||||||
|
rank: r,
|
||||||
|
activityId: a.id,
|
||||||
|
isoDate: a.date,
|
||||||
|
subject: a.subject ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort((x, y) => x.t - y.t);
|
||||||
|
|
||||||
|
const ranges: StageRange[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const cur = items[i];
|
||||||
|
let endIso: string | null = null;
|
||||||
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
|
if (items[j].rank > cur.rank) {
|
||||||
|
endIso = items[j].isoDate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push({
|
||||||
|
rank: cur.rank,
|
||||||
|
startDate: cur.isoDate,
|
||||||
|
endDate: endIso,
|
||||||
|
activityId: cur.activityId,
|
||||||
|
subject: cur.subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user