Skip to content

Commit 6ca6f69

Browse files
iplaycellotaylorcox75claude
authored
Truncate (floor) progress and availability instead of rounding up (#85)
* Truncate (floor) progress and availability instead of rounding up Availability of 0.999 rounded to 1.00 is misleading — in torrents, anything below 1.0 means the complete file cannot be assembled from available peers. Progress similarly should not round up to 100% when the download is not yet complete. * release: bump version to 3.7.12, open new changelog entry 3.7.2 entry is released/in production and stays untouched; 3.7.12 is a new top entry for the next release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CG6NY9fDZjqC5f1yA2tEMz * changelog: fill 3.7.12 entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CG6NY9fDZjqC5f1yA2tEMz --------- Co-authored-by: taylorcox75 <taylorcox75@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 34307d8 commit 6ca6f69

7 files changed

Lines changed: 142 additions & 10 deletions

File tree

app/torrent/[hash].tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
TorrentFile,
4848
TorrentInfo,
4949
} from '@/types/api';
50-
import { formatDate } from '@/utils/format';
50+
import { formatDate, formatProgress, formatAvailability } from '@/utils/format';
5151
import { getErrorMessage } from '@/utils/error';
5252
import { haptics } from '@/utils/haptics';
5353

@@ -914,7 +914,7 @@ export default function TorrentDetail() {
914914
<Text style={[styles.heroStatText, { color: colors.textSecondary }]}>
915915
{hasEta(torrent.eta, torrent.progress)
916916
? t('torrentDetail.remaining', { time: formatTime(torrent.eta) })
917-
: progress >= 100
917+
: torrent.progress >= 1
918918
? t('torrentDetail.complete')
919919
: '∞'}
920920
</Text>
@@ -1005,7 +1005,7 @@ export default function TorrentDetail() {
10051005
`${torrent.num_leechs || 0} / ${torrent.num_incomplete || 0}`,
10061006
handleOpenPeerDetails,
10071007
),
1008-
staticRow(t('torrentDetail.availability'), torrent.availability ? torrent.availability.toFixed(2) : '0.00'),
1008+
staticRow(t('torrentDetail.availability'), torrent.availability > 0 ? formatAvailability(torrent.availability) : '0.000'),
10091009
torrent.popularity != null && staticRow(t('torrentDetail.popularity'), torrent.popularity.toFixed(2)),
10101010
])}
10111011
</View>
@@ -1165,7 +1165,7 @@ export default function TorrentDetail() {
11651165
style={[styles.peerRow, { borderBottomColor: colors.surfaceOutline }]}
11661166
>
11671167
<Text style={[styles.peerProgress, { color: colors.text }]}>
1168-
{(p.progress * 100).toFixed(1)}%
1168+
{formatProgress(p.progress)}
11691169
</Text>
11701170
<View style={styles.peerInfo}>
11711171
<Text style={[styles.peerIp, { color: colors.text }]} numberOfLines={1}>

app/torrent/files.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { InputModal } from '@/components/InputModal';
2727
import { EmptyState } from '@/components/EmptyState';
2828
import { torrentsApi } from '@/services/api/torrents';
2929
import { TorrentFile, FilePriority } from '@/types/api';
30+
import { formatProgress } from '@/utils/format';
3031
import { spacing, borderRadius } from '@/constants/spacing';
3132
import { shadows } from '@/constants/shadows';
3233
import { getErrorMessage } from '@/utils/error';
@@ -621,7 +622,7 @@ export default function TorrentFilesScreen() {
621622
</Text>
622623
<Text style={[styles.fileSeparator, { color: colors.textSecondary }]}></Text>
623624
<Text style={[styles.fileProgress, { color: colors.textSecondary }]}>
624-
{(file.progress * 100).toFixed(1)}%
625+
{formatProgress(file.progress)}
625626
</Text>
626627
</View>
627628
</View>

components/TorrentCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useTheme } from '@/context/ThemeContext';
77
import { AnimatedProgressBar } from '@/components/AnimatedProgressBar';
88
import { haptics } from '@/utils/haptics';
99
import { getStateColor, getStateLabel, hasEta, isTorrentComplete } from '@/utils/torrent-state';
10-
import { formatSpeed, formatSize, formatTime } from '@/utils/format';
10+
import { formatSpeed, formatSize, formatTime, formatProgress, formatAvailability } from '@/utils/format';
1111
import { spacing, borderRadius } from '@/constants/spacing';
1212
import { shadows } from '@/constants/shadows';
1313
import { typography } from '@/constants/typography';
@@ -154,7 +154,7 @@ function TorrentCardInner({
154154
const statusLine = [
155155
stateLabel,
156156
speedText,
157-
`${progress.toFixed(0)}%`,
157+
formatProgress(torrent.progress, 0),
158158
etaVisible ? formatTime(torrent.eta) : null,
159159
!etaVisible && seeding ? `${t('torrentDetail.ratio')}: ${(torrent.ratio ?? 0).toFixed(2)}` : null,
160160
].filter(Boolean).join(' · ');
@@ -187,7 +187,7 @@ function TorrentCardInner({
187187
};
188188

189189
if (show('status')) addItem('status', t('screens.settings.expandedCardFieldsList.status'), stateLabel);
190-
if (show('progress')) addItem('progress', t('screens.settings.expandedCardFieldsList.progress'), `${progress.toFixed(1)}%`);
190+
if (show('progress')) addItem('progress', t('screens.settings.expandedCardFieldsList.progress'), formatProgress(torrent.progress));
191191
if (show('dlSpeed')) addItem('dlSpeed', t('screens.settings.expandedCardFieldsList.dlSpeed'), dlspeed > 0 ? formatSpeed(dlspeed) : '—');
192192
if (show('ulSpeed')) addItem('ulSpeed', t('screens.settings.expandedCardFieldsList.ulSpeed'), upspeed > 0 ? formatSpeed(upspeed) : '—');
193193
if (show('eta') && etaVisible) addItem('eta', t('screens.settings.expandedCardFieldsList.eta'), formatTime(torrent.eta));
@@ -197,7 +197,7 @@ function TorrentCardInner({
197197
if (show('ratioLimit')) addItem('ratioLimit', t('screens.settings.expandedCardFieldsList.ratioLimit'), torrent.ratio_limit != null && torrent.ratio_limit >= 0 ? torrent.ratio_limit.toFixed(2) : '∞');
198198
if (show('maxRatio')) addItem('maxRatio', t('screens.settings.expandedCardFieldsList.maxRatio'), torrent.max_ratio != null && torrent.max_ratio >= 0 ? torrent.max_ratio.toFixed(2) : '∞');
199199
if (show('uploaded')) addItem('uploaded', t('screens.settings.expandedCardFieldsList.uploaded'), formatSize(torrent.uploaded));
200-
if (show('availability')) addItem('availability', t('screens.settings.expandedCardFieldsList.availability'), torrent.availability > 0 && torrent.availability < 1 ? `${(torrent.availability * 100).toFixed(1)}%` : '—');
200+
if (show('availability')) addItem('availability', t('screens.settings.expandedCardFieldsList.availability'), torrent.availability > 0 ? formatAvailability(torrent.availability) : '—');
201201
if (show('popularity') && torrent.popularity != null) addItem('popularity', t('screens.settings.expandedCardFieldsList.popularity'), torrent.popularity.toFixed(2));
202202
if (show('seedingTime')) addItem('seedingTime', t('screens.settings.expandedCardFieldsList.seedingTime'), torrent.seeding_time > 0 ? formatTime(torrent.seeding_time) : '—');
203203
if (show('addedOn')) addItem('addedOn', t('screens.settings.expandedCardFieldsList.addedOn'), new Date(torrent.added_on * 1000).toLocaleDateString());

constants/changelog.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ export interface ChangelogRelease {
1111
}
1212

1313
export const CHANGELOG: ChangelogRelease[] = [
14+
{
15+
version: '3.7.12',
16+
date: '2026-07-16',
17+
changes: [
18+
'Progress and availability now truncate instead of rounding up — an incomplete torrent never shows "100%", and availability below 1.0 never shows "1.000" (below 1.0 the complete file cannot be assembled from connected peers)',
19+
'Availability is now shown as a ratio to 3 decimals (matching qBittorrent) and appears on the expanded card even when above 1.0',
20+
],
21+
},
22+
{
23+
version: '3.7.2',
24+
date: '2026-07-16',
25+
changes: [
26+
'Progress and availability now truncate instead of rounding up — a torrent at 99.96% no longer shows "100%", and availability 0.9999 shows "0.999" instead of "1.00" (below 1.0 the complete file cannot be assembled from connected peers)',
27+
'Availability is now shown as a ratio to 3 decimals (matching qBittorrent) and appears on the expanded card even when above 1.0',
28+
],
29+
},
1430
{
1531
version: '3.7.1',
1632
date: '2026-07-16',

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "qRemote",
3-
"version": "3.7.1",
3+
"version": "3.7.12",
44
"main": "index.ts",
55
"scripts": {
66
"start": "expo start --go",

tests/utils/format.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
formatDate,
66
formatRatio,
77
formatPercent,
8+
floorTo,
9+
formatProgress,
10+
formatAvailability,
811
} from '@/utils/format';
912

1013
describe('formatSize', () => {
@@ -240,3 +243,75 @@ describe('formatPercent', () => {
240243
expect(formatPercent(-0.1)).toBe('-10.0%');
241244
});
242245
});
246+
247+
describe('floorTo', () => {
248+
it('floors without rounding up', () => {
249+
expect(floorTo(99.99, 1)).toBe(99.9);
250+
expect(floorTo(99.95, 0)).toBe(99);
251+
});
252+
253+
it('does not truncate exact decimals that binary floats under-represent', () => {
254+
// 0.29 * 100 === 28.999999999999996 — naive Math.floor gives 28
255+
expect(floorTo(0.29 * 100, 0)).toBe(29);
256+
expect(floorTo(0.58 * 100, 0)).toBe(58);
257+
expect(floorTo(1.005, 3)).toBe(1.005);
258+
});
259+
260+
it('never pushes a genuinely-below-boundary value across it', () => {
261+
expect(floorTo(99.9999, 0)).toBe(99);
262+
expect(floorTo(0.9999, 3)).toBe(0.999);
263+
});
264+
});
265+
266+
describe('formatProgress', () => {
267+
it('returns zero for null, undefined, and NaN', () => {
268+
expect(formatProgress(null)).toBe('0.0%');
269+
expect(formatProgress(undefined)).toBe('0.0%');
270+
expect(formatProgress(NaN)).toBe('0.0%');
271+
expect(formatProgress(null, 0)).toBe('0%');
272+
});
273+
274+
it('truncates instead of rounding up near completion', () => {
275+
expect(formatProgress(0.9995)).toBe('99.9%');
276+
expect(formatProgress(0.999999)).toBe('99.9%');
277+
expect(formatProgress(0.995, 0)).toBe('99%');
278+
});
279+
280+
it('shows 100% only at exactly complete', () => {
281+
expect(formatProgress(1)).toBe('100.0%');
282+
expect(formatProgress(1, 0)).toBe('100%');
283+
});
284+
285+
it('does not understate exact percentages (float one-ULP guard)', () => {
286+
expect(formatProgress(0.29, 0)).toBe('29%');
287+
expect(formatProgress(0.58, 0)).toBe('58%');
288+
expect(formatProgress(0.723)).toBe('72.3%');
289+
});
290+
291+
it('truncates mid-range extra precision', () => {
292+
expect(formatProgress(0.8615)).toBe('86.1%');
293+
});
294+
});
295+
296+
describe('formatAvailability', () => {
297+
it('returns "0.000" for null, undefined, and NaN', () => {
298+
expect(formatAvailability(null)).toBe('0.000');
299+
expect(formatAvailability(undefined)).toBe('0.000');
300+
expect(formatAvailability(NaN)).toBe('0.000');
301+
});
302+
303+
it('truncates instead of rounding up to 1.000', () => {
304+
expect(formatAvailability(0.9999)).toBe('0.999');
305+
expect(formatAvailability(0.99999)).toBe('0.999');
306+
});
307+
308+
it('shows 1.000 only at exactly 1', () => {
309+
expect(formatAvailability(1)).toBe('1.000');
310+
});
311+
312+
it('handles ratios above 1 and float one-ULP values', () => {
313+
expect(formatAvailability(2.5)).toBe('2.500');
314+
// 1.005 * 1000 === 1004.9999999999999 — naive Math.floor gives 1.004
315+
expect(formatAvailability(1.005)).toBe('1.005');
316+
});
317+
});

utils/format.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,46 @@ export const formatPercent = (value: number | undefined | null): string => {
7979
return `${(value * 100).toFixed(1)}%`;
8080
};
8181

82+
/**
83+
* One-ULP guard for flooring binary floats that represent exact decimals:
84+
* 0.29 * 100 === 28.999999999999996, which Math.floor would truncate to 28.
85+
* Small enough that a genuinely-below-boundary value (e.g. 0.9999) can never
86+
* be pushed across it.
87+
*/
88+
const FLOOR_EPSILON = 1e-6;
89+
90+
/**
91+
* Floor a value to `decimals` places without ever rounding up.
92+
*/
93+
export const floorTo = (value: number, decimals: number): number => {
94+
const factor = Math.pow(10, decimals);
95+
return Math.floor(value * factor + FLOOR_EPSILON) / factor;
96+
};
97+
98+
/**
99+
* Format a 0-1 progress fraction as a truncated percentage string.
100+
* Truncates rather than rounds so an incomplete torrent (0.9995) never
101+
* displays as "100%" — below 1.0 the file cannot yet be assembled.
102+
*/
103+
export const formatProgress = (
104+
fraction: number | undefined | null,
105+
decimals: number = 1,
106+
): string => {
107+
if (fraction == null || isNaN(fraction)) return `${(0).toFixed(decimals)}%`;
108+
return `${floorTo(fraction * 100, decimals).toFixed(decimals)}%`;
109+
};
110+
111+
/**
112+
* Format an availability ratio truncated to 3 decimals (e.g. 0.9999 -> "0.999").
113+
* Truncates rather than rounds because availability just below 1.0 means the
114+
* complete file cannot be assembled from currently-connected peers — rounding
115+
* up to "1.000" hides exactly the state the user needs to see.
116+
*/
117+
export const formatAvailability = (availability: number | undefined | null): string => {
118+
if (availability == null || isNaN(availability)) return '0.000';
119+
return floorTo(availability, 3).toFixed(3);
120+
};
121+
82122
/**
83123
* Format a Unix timestamp to a locale date string
84124
* Returns "Not provided" for invalid timestamps (0, -1, or undefined)

0 commit comments

Comments
 (0)