@@ -1594,7 +1594,7 @@ export function buildSessionExercisesPayload(
15941594 prSetIds : PrSetMap ,
15951595 startedAtMs ?: number | null
15961596) : PresetSessionExerciseRequest [ ] {
1597- const durationByEntryId = buildSessionDurationMinutes (
1597+ const timing = buildSessionExerciseTiming (
15981598 session ,
15991599 completedSetIds ,
16001600 startedAtMs
@@ -1608,7 +1608,11 @@ export function buildSessionExercisesPayload(
16081608 resolveSnapshotModality ( exercise . exercise_snapshot )
16091609 )
16101610 ? setsDurationMinutes ( exercise . sets )
1611- : ( durationByEntryId ?. get ( exercise . id ) ?? exercise . duration_minutes ?? 0 ) ,
1611+ : ( timing ?. durations . get ( exercise . id ) ?? exercise . duration_minutes ?? 0 ) ,
1612+ // Omitted when nothing was completed yet, so the server keeps what it has.
1613+ ...( timing ?. startTimes . has ( exercise . id )
1614+ ? { entry_time : timing . startTimes . get ( exercise . id ) }
1615+ : { } ) ,
16121616 notes : exercise . notes ?? null ,
16131617 superset_group : exercise . superset_group ?? null ,
16141618 sets : exercise . sets . map ( ( set , setIndex ) => {
@@ -1638,12 +1642,47 @@ export function buildSessionDurationMinutes(
16381642 completedSetIds : CompletedSetMap ,
16391643 startedAtMs ?: number | null
16401644) : Map < string , number > | null {
1645+ return (
1646+ buildSessionExerciseTiming ( session , completedSetIds , startedAtMs )
1647+ ?. durations ?? null
1648+ ) ;
1649+ }
1650+
1651+ export interface SessionExerciseTiming {
1652+ /** Minutes per non-cardio entry; entries absent here keep their stored value. */
1653+ durations : Map < string , number > ;
1654+ /** Local wall-clock 'HH:MM' at which each entry's first timed span began. */
1655+ startTimes : Map < string , string > ;
1656+ }
1657+
1658+ /** Local wall-clock 'HH:MM' for an instant, the format entry_time expects. */
1659+ export function toEntryTimeString ( ms : number ) : string {
1660+ const date = new Date ( ms ) ;
1661+ const pad = ( n : number ) => n . toString ( ) . padStart ( 2 , '0' ) ;
1662+ return `${ pad ( date . getHours ( ) ) } :${ pad ( date . getMinutes ( ) ) } ` ;
1663+ }
1664+
1665+ /**
1666+ * Walks the completed strength sets in the order they were ticked off and
1667+ * gives each gap (previous completion, or the workout start, up to this
1668+ * completion) to the exercise the set belongs to. That is the time spent on
1669+ * that exercise's set plus the rest before it, so supersets and exercises
1670+ * done out of order each get their own share. The per-entry durations still
1671+ * add up to start→last completion.
1672+ *
1673+ * An entry's start time is the beginning of its first gap. Cardio entries
1674+ * are left out: their duration is the sum of their set durations.
1675+ */
1676+ export function buildSessionExerciseTiming (
1677+ session : PresetSessionResponse ,
1678+ completedSetIds : CompletedSetMap ,
1679+ startedAtMs ?: number | null
1680+ ) : SessionExerciseTiming | null {
16411681 if ( startedAtMs == null ) return null ;
16421682
1643- let lastCompletedMs = 0 ;
1644- let totalCompleted = 0 ;
16451683 let anyCompletedAfterStart = false ;
16461684 const completedCountByEntryId = new Map < string , number > ( ) ;
1685+ const timeline : { ms : number ; entryId : string } [ ] = [ ] ;
16471686 for ( const exercise of session . exercises ) {
16481687 const cardio = isCardioModality (
16491688 resolveSnapshotModality ( exercise . exercise_snapshot )
@@ -1655,28 +1694,34 @@ export function buildSessionDurationMinutes(
16551694 if ( ms > startedAtMs ) anyCompletedAfterStart = true ;
16561695 if ( cardio ) continue ;
16571696 count ++ ;
1658- totalCompleted ++ ;
1659- if ( ms > lastCompletedMs ) lastCompletedMs = ms ;
1697+ // Completions before the start (a resumed session) can't be placed on
1698+ // this clock; their entry keeps its stored duration.
1699+ if ( ms > startedAtMs ) timeline . push ( { ms, entryId : exercise . id } ) ;
16601700 }
16611701 if ( ! cardio ) completedCountByEntryId . set ( exercise . id , count ) ;
16621702 }
1663- if ( totalCompleted === 0 || lastCompletedMs <= startedAtMs ) {
1664- if ( ! anyCompletedAfterStart ) return null ;
1665- const zeroed = new Map < string , number > ( ) ;
1666- for ( const [ entryId , count ] of completedCountByEntryId ) {
1667- if ( count === 0 ) zeroed . set ( entryId , 0 ) ;
1668- }
1669- return zeroed ;
1703+ if ( ! anyCompletedAfterStart ) return null ;
1704+
1705+ const durations = new Map < string , number > ( ) ;
1706+ const startTimes = new Map < string , string > ( ) ;
1707+ for ( const [ entryId , count ] of completedCountByEntryId ) {
1708+ if ( count === 0 ) durations . set ( entryId , 0 ) ;
16701709 }
16711710
1672- const totalMinutes = ( lastCompletedMs - startedAtMs ) / 60_000 ;
1673- const byEntryId = new Map < string , number > ( ) ;
1674- for ( const exercise of session . exercises ) {
1675- const count = completedCountByEntryId . get ( exercise . id ) ?? 0 ;
1676- const share = ( totalMinutes * count ) / totalCompleted ;
1677- byEntryId . set ( exercise . id , Math . round ( share * 10 ) / 10 ) ;
1711+ timeline . sort ( ( a , b ) => a . ms - b . ms ) ;
1712+ const spanMs = new Map < string , number > ( ) ;
1713+ let previousMs = startedAtMs ;
1714+ for ( const { ms, entryId } of timeline ) {
1715+ spanMs . set ( entryId , ( spanMs . get ( entryId ) ?? 0 ) + ( ms - previousMs ) ) ;
1716+ if ( ! startTimes . has ( entryId ) ) {
1717+ startTimes . set ( entryId , toEntryTimeString ( previousMs ) ) ;
1718+ }
1719+ previousMs = ms ;
1720+ }
1721+ for ( const [ entryId , ms ] of spanMs ) {
1722+ durations . set ( entryId , Math . round ( ( ms / 60_000 ) * 10 ) / 10 ) ;
16781723 }
1679- return byEntryId ;
1724+ return { durations , startTimes } ;
16801725}
16811726
16821727export const WORKOUT_LONG_GAP_MINUTES = 30 ;
0 commit comments