diff --git a/tests/pxt-editor-test/editorrunner.ts b/tests/pxt-editor-test/editorrunner.ts index 385427c2e4ee..d063f5071289 100644 --- a/tests/pxt-editor-test/editorrunner.ts +++ b/tests/pxt-editor-test/editorrunner.ts @@ -211,7 +211,6 @@ for (let i = 0; i < 20; i++) { const ONE_MINUTE = 1000 * 60; const ONE_HOUR = ONE_MINUTE * 60; -const ONE_DAY = ONE_HOUR * 24; describe("updateHistory", () => { diff --git a/webapp/src/timeMachine.tsx b/webapp/src/timeMachine.tsx index ce668a7219b3..1a2d8875422c 100644 --- a/webapp/src/timeMachine.tsx +++ b/webapp/src/timeMachine.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { Button } from "../../react-common/components/controls/Button"; import { hideDialog } from "./core"; import { FocusTrap } from "../../react-common/components/controls/FocusTrap"; +import { classList } from "../../react-common/components/util"; interface TimeMachineProps { onProjectLoad: (text: pxt.workspace.ScriptText, editorVersion: string, timestamp?: number) => void; @@ -18,9 +19,15 @@ interface PendingMessage { handler: (response: any) => void; } +interface TimelineEntry { + label: string; + entries: TimeEntry[]; +} + interface TimeEntry { label: string; timestamp: number; + kind: "snapshot" | "diff"; } interface Project { @@ -35,7 +42,8 @@ export const TimeMachine = (props: TimeMachineProps) => { // -1 here is a standin for "now" const [selected, setSelected] = React.useState(-1); - const [loading, setLoading] = React.useState("loading") + const [loading, setLoading] = React.useState("loading"); + const [entries, setEntries] = React.useState(getTimelineEntries(history)); const iframeRef = React.useRef(); @@ -150,6 +158,10 @@ export const TimeMachine = (props: TimeMachineProps) => { } }, [loading, importProject.current, text]); + React.useEffect(() => { + setEntries(getTimelineEntries(history)); + }, [history]); + const onTimeSelected = async (newValue: number) => { if (importProject.current) { setSelected(newValue); @@ -185,46 +197,6 @@ export const TimeMachine = (props: TimeMachineProps) => { const url = `${window.location.origin + window.location.pathname}?timeMachine=1&controller=1&skillsMap=1&noproject=1&nocookiebanner=1`; - const buckets: {[index: string]: TimeEntry[]} = {}; - - for (const entry of history.entries) { - const date = new Date(entry.timestamp); - const key = new Date(date.toLocaleDateString( - pxt.U.userLanguage(), - { - year: "numeric", - month: "numeric", - day: "numeric" - } - )).getTime(); - - if (!buckets[key]) { - buckets[key] = []; - } - - buckets[key].push({ - label: formatTime(entry.timestamp), - timestamp: entry.timestamp - }); - } - - const nowEntry = { - label: lf("Now"), - timestamp: -1 - }; - - const sortedBuckets = Object.keys(buckets).sort((a, b) => parseInt(b) - parseInt(a)); - for (const bucket of sortedBuckets) { - buckets[bucket].sort((a, b) => b.timestamp - a.timestamp); - } - - if (!sortedBuckets.length || !isToday(parseInt(sortedBuckets[0]))) { - buckets[Date.now()] = [nowEntry] - } - else { - buckets[sortedBuckets[0]].unshift(nowEntry) - } - return createPortal(
@@ -277,17 +249,17 @@ export const TimeMachine = (props: TimeMachineProps) => {
- {sortedBuckets.map((date, i) => - + {entries.map((e, i) => + - {formatDate(parseInt(date))} + {e.label} - {...buckets[date].map(entry => + {e.entries.map(entry => onTimeSelected(entry.timestamp)} - className={selected === entry.timestamp ? "selected" : undefined} + className={classList(selected === entry.timestamp && "selected", entry.kind)} > {entry.label} @@ -441,4 +413,92 @@ function isToday(time: number) { return now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() == date.getDate(); +} + +function getTimelineEntries(history: pxt.workspace.HistoryFile): TimelineEntry[] { + const buckets: {[index: string]: TimeEntry[]} = {}; + + const createTimeEntry = (timestamp: number, kind: "snapshot" | "diff") => { + const date = new Date(timestamp); + const key = new Date(date.toLocaleDateString( + pxt.U.userLanguage(), + { + year: "numeric", + month: "numeric", + day: "numeric" + } + )).getTime(); + + if (!buckets[key]) { + buckets[key] = []; + } + + buckets[key].push({ + label: formatTime(timestamp), + timestamp, + kind + }); + } + + for (const entry of history.entries) { + createTimeEntry(entry.timestamp, "diff"); + } + + for (const entry of history.snapshots) { + createTimeEntry(entry.timestamp, "snapshot"); + } + + const sortedBuckets = Object.keys(buckets).sort((a, b) => parseInt(b) - parseInt(a)); + for (const bucketKey of sortedBuckets) { + const bucket = buckets[bucketKey]; + const deduped: TimeEntry[] = []; + + // Deduplicate entries that exist in the same minute and sort + for (const entry of bucket) { + const eIndex = deduped.findIndex(e => e.label === entry.label); + const existing = deduped[eIndex]; + + if (existing) { + // We generally prefer snapshots to diffs. Otherwise take the latest + if (existing.kind === entry.kind) { + if (entry.timestamp > existing.timestamp) { + deduped.splice(eIndex, 1, entry); + } + } + else if (existing.kind === "snapshot") { + continue; + } + else if (existing.kind === "diff") { + deduped.splice(eIndex, 1, entry); + } + } + else { + deduped.push(entry); + } + } + + deduped.sort((a, b) => b.timestamp - a.timestamp); + buckets[bucketKey] = deduped; + } + + // Always add an entry for "now" + const nowEntry: TimeEntry = { + label: lf("Now"), + timestamp: -1, + kind: "snapshot" + }; + + if (!sortedBuckets.length || !isToday(parseInt(sortedBuckets[0]))) { + buckets[Date.now()] = [nowEntry] + } + else { + buckets[sortedBuckets[0]].unshift(nowEntry) + } + + return sortedBuckets.map(key => ( + { + label: formatDate(parseInt(key)), + entries: buckets[key] + } as TimelineEntry + )) } \ No newline at end of file