mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12feb633f |
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
|
|||||||
await openCompactUsageWithClock(page, now);
|
await openCompactUsageWithClock(page, now);
|
||||||
const card = page.getByTestId("usage-report-login-journey:account");
|
const card = page.getByTestId("usage-report-login-journey:account");
|
||||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
|
||||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||||
await advanceUsageClock(page, 120_000);
|
await advanceUsageClock(page, 120_000);
|
||||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
|
||||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
|
||||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||||
/^resets (28|29)m$/,
|
/^resets in (28|29)m\s·\s/,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await login.cleanup();
|
await login.cleanup();
|
||||||
|
|||||||
@@ -2157,6 +2157,7 @@ export const ar: TranslationResources = {
|
|||||||
minutes_few: "{{value}} دقائق",
|
minutes_few: "{{value}} دقائق",
|
||||||
minutes_many: "{{value}} دقيقة",
|
minutes_many: "{{value}} دقيقة",
|
||||||
minutes_other: "{{value}} دقيقة",
|
minutes_other: "{{value}} دقيقة",
|
||||||
|
pair: "{{first}} و{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "آخر تحديث {{relativeTime}}",
|
lastUpdated: "آخر تحديث {{relativeTime}}",
|
||||||
@@ -2164,6 +2165,8 @@ export const ar: TranslationResources = {
|
|||||||
resetTimePassed: "مضى وقت إعادة التعيين",
|
resetTimePassed: "مضى وقت إعادة التعيين",
|
||||||
resettingNow: "تتم إعادة التعيين الآن",
|
resettingNow: "تتم إعادة التعيين الآن",
|
||||||
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}، {{time}}",
|
||||||
runsOutNow: "ينفد الآن",
|
runsOutNow: "ينفد الآن",
|
||||||
runsOutIn: "ينفد خلال {{duration}}",
|
runsOutIn: "ينفد خلال {{duration}}",
|
||||||
justNow: "الآن",
|
justNow: "الآن",
|
||||||
|
|||||||
@@ -2164,15 +2164,18 @@ export const en = {
|
|||||||
days: "{{value}}d",
|
days: "{{value}}d",
|
||||||
hours: "{{value}}h",
|
hours: "{{value}}h",
|
||||||
minutes: "{{value}}m",
|
minutes: "{{value}}m",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Last updated {{relativeTime}}",
|
lastUpdated: "Last updated {{relativeTime}}",
|
||||||
noSuccessfulUpdate: "No successful update",
|
noSuccessfulUpdate: "No successful update",
|
||||||
resetTimePassed: "Reset time passed",
|
resetTimePassed: "Reset time passed",
|
||||||
resettingNow: "resetting now",
|
resettingNow: "resetting now",
|
||||||
resetsIn: "resets {{duration}}",
|
resetsIn: "resets in {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "runs out now",
|
runsOutNow: "runs out now",
|
||||||
runsOutIn: "runs out {{duration}}",
|
runsOutIn: "runs out in {{duration}}",
|
||||||
justNow: "just now",
|
justNow: "just now",
|
||||||
daysAgo: "{{value}}d ago",
|
daysAgo: "{{value}}d ago",
|
||||||
hoursAgo: "{{value}}h ago",
|
hoursAgo: "{{value}}h ago",
|
||||||
|
|||||||
@@ -2187,6 +2187,7 @@ export const es: TranslationResources = {
|
|||||||
days: "{{value}} d",
|
days: "{{value}} d",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Última actualización {{relativeTime}}",
|
lastUpdated: "Última actualización {{relativeTime}}",
|
||||||
@@ -2194,6 +2195,8 @@ export const es: TranslationResources = {
|
|||||||
resetTimePassed: "Pasó la hora de restablecimiento",
|
resetTimePassed: "Pasó la hora de restablecimiento",
|
||||||
resettingNow: "se restablece ahora",
|
resettingNow: "se restablece ahora",
|
||||||
resetsIn: "se restablece en {{duration}}",
|
resetsIn: "se restablece en {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "se agota ahora",
|
runsOutNow: "se agota ahora",
|
||||||
runsOutIn: "se agota en {{duration}}",
|
runsOutIn: "se agota en {{duration}}",
|
||||||
justNow: "ahora mismo",
|
justNow: "ahora mismo",
|
||||||
|
|||||||
@@ -2187,6 +2187,7 @@ export const fr: TranslationResources = {
|
|||||||
days: "{{value}} j",
|
days: "{{value}} j",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
||||||
@@ -2194,6 +2195,8 @@ export const fr: TranslationResources = {
|
|||||||
resetTimePassed: "Heure de réinitialisation dépassée",
|
resetTimePassed: "Heure de réinitialisation dépassée",
|
||||||
resettingNow: "réinitialisation en cours",
|
resettingNow: "réinitialisation en cours",
|
||||||
resetsIn: "se réinitialise dans {{duration}}",
|
resetsIn: "se réinitialise dans {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "s’épuise maintenant",
|
runsOutNow: "s’épuise maintenant",
|
||||||
runsOutIn: "s’épuise dans {{duration}}",
|
runsOutIn: "s’épuise dans {{duration}}",
|
||||||
justNow: "à l’instant",
|
justNow: "à l’instant",
|
||||||
|
|||||||
@@ -2160,6 +2160,7 @@ export const ja: TranslationResources = {
|
|||||||
days: "{{value}}日",
|
days: "{{value}}日",
|
||||||
hours: "{{value}}時間",
|
hours: "{{value}}時間",
|
||||||
minutes: "{{value}}分",
|
minutes: "{{value}}分",
|
||||||
|
pair: "{{first}}{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "最終更新: {{relativeTime}}",
|
lastUpdated: "最終更新: {{relativeTime}}",
|
||||||
@@ -2167,6 +2168,8 @@ export const ja: TranslationResources = {
|
|||||||
resetTimePassed: "リセット時刻を過ぎています",
|
resetTimePassed: "リセット時刻を過ぎています",
|
||||||
resettingNow: "まもなくリセット",
|
resettingNow: "まもなくリセット",
|
||||||
resetsIn: "{{duration}}後にリセット",
|
resetsIn: "{{duration}}後にリセット",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "まもなく上限に到達",
|
runsOutNow: "まもなく上限に到達",
|
||||||
runsOutIn: "{{duration}}後に上限に到達",
|
runsOutIn: "{{duration}}後に上限に到達",
|
||||||
justNow: "たった今",
|
justNow: "たった今",
|
||||||
|
|||||||
@@ -2149,6 +2149,7 @@ export const ko: TranslationResources = {
|
|||||||
days: "{{value}}일",
|
days: "{{value}}일",
|
||||||
hours: "{{value}}시간",
|
hours: "{{value}}시간",
|
||||||
minutes: "{{value}}분",
|
minutes: "{{value}}분",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
||||||
@@ -2156,6 +2157,8 @@ export const ko: TranslationResources = {
|
|||||||
resetTimePassed: "초기화 시간이 지났습니다",
|
resetTimePassed: "초기화 시간이 지났습니다",
|
||||||
resettingNow: "지금 재설정 중",
|
resettingNow: "지금 재설정 중",
|
||||||
resetsIn: "{{duration}} 후 재설정",
|
resetsIn: "{{duration}} 후 재설정",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "지금 한도에 도달",
|
runsOutNow: "지금 한도에 도달",
|
||||||
runsOutIn: "{{duration}} 후 한도에 도달",
|
runsOutIn: "{{duration}} 후 한도에 도달",
|
||||||
justNow: "방금",
|
justNow: "방금",
|
||||||
|
|||||||
@@ -2172,6 +2172,7 @@ export const ptBR: TranslationResources = {
|
|||||||
days: "{{value}} d",
|
days: "{{value}} d",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Última atualização {{relativeTime}}",
|
lastUpdated: "Última atualização {{relativeTime}}",
|
||||||
@@ -2179,6 +2180,8 @@ export const ptBR: TranslationResources = {
|
|||||||
resetTimePassed: "O horário de redefinição passou",
|
resetTimePassed: "O horário de redefinição passou",
|
||||||
resettingNow: "redefinindo agora",
|
resettingNow: "redefinindo agora",
|
||||||
resetsIn: "redefine em {{duration}}",
|
resetsIn: "redefine em {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "esgota agora",
|
runsOutNow: "esgota agora",
|
||||||
runsOutIn: "esgota em {{duration}}",
|
runsOutIn: "esgota em {{duration}}",
|
||||||
justNow: "agora mesmo",
|
justNow: "agora mesmo",
|
||||||
|
|||||||
@@ -2172,6 +2172,7 @@ export const ru: TranslationResources = {
|
|||||||
days: "{{value}} дн.",
|
days: "{{value}} дн.",
|
||||||
hours: "{{value}} ч",
|
hours: "{{value}} ч",
|
||||||
minutes: "{{value}} мин",
|
minutes: "{{value}} мин",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Последнее обновление {{relativeTime}}",
|
lastUpdated: "Последнее обновление {{relativeTime}}",
|
||||||
@@ -2179,6 +2180,8 @@ export const ru: TranslationResources = {
|
|||||||
resetTimePassed: "Время сброса прошло",
|
resetTimePassed: "Время сброса прошло",
|
||||||
resettingNow: "сброс сейчас",
|
resettingNow: "сброс сейчас",
|
||||||
resetsIn: "сброс через {{duration}}",
|
resetsIn: "сброс через {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "закончится сейчас",
|
runsOutNow: "закончится сейчас",
|
||||||
runsOutIn: "закончится через {{duration}}",
|
runsOutIn: "закончится через {{duration}}",
|
||||||
justNow: "только что",
|
justNow: "только что",
|
||||||
|
|||||||
@@ -2117,6 +2117,7 @@ export const zhCN: TranslationResources = {
|
|||||||
days: "{{value}}天",
|
days: "{{value}}天",
|
||||||
hours: "{{value}}小时",
|
hours: "{{value}}小时",
|
||||||
minutes: "{{value}}分钟",
|
minutes: "{{value}}分钟",
|
||||||
|
pair: "{{first}}{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "上次更新于 {{relativeTime}}",
|
lastUpdated: "上次更新于 {{relativeTime}}",
|
||||||
@@ -2124,6 +2125,8 @@ export const zhCN: TranslationResources = {
|
|||||||
resetTimePassed: "重置时间已过",
|
resetTimePassed: "重置时间已过",
|
||||||
resettingNow: "正在重置",
|
resettingNow: "正在重置",
|
||||||
resetsIn: "将在 {{duration}}后重置",
|
resetsIn: "将在 {{duration}}后重置",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "即将用尽",
|
runsOutNow: "即将用尽",
|
||||||
runsOutIn: "将在 {{duration}}后用尽",
|
runsOutIn: "将在 {{duration}}后用尽",
|
||||||
justNow: "刚刚",
|
justNow: "刚刚",
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
|
import {
|
||||||
import type { UsageBalance, UsageTone } from "./types";
|
formatAmount,
|
||||||
|
formatProviderUsageLabel,
|
||||||
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
|
} from "./format";
|
||||||
|
import { UsageMeter } from "./meter";
|
||||||
|
import { UsageResetLine } from "./reset-line";
|
||||||
|
import type { UsageBalance } from "./types";
|
||||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||||
|
|
||||||
interface ResolvedBalance {
|
interface ResolvedBalance {
|
||||||
@@ -41,19 +47,6 @@ function resolveBalance({
|
|||||||
return { amountText: "—", usedPct: null };
|
return { amountText: "—", usedPct: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillToneStyle(tone: UsageTone) {
|
|
||||||
switch (tone) {
|
|
||||||
case "ok":
|
|
||||||
return styles.fillOk;
|
|
||||||
case "warning":
|
|
||||||
return styles.fillWarning;
|
|
||||||
case "danger":
|
|
||||||
return styles.fillDanger;
|
|
||||||
default:
|
|
||||||
return styles.fillDefault;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsageBalanceBar({
|
export function UsageBalanceBar({
|
||||||
balance,
|
balance,
|
||||||
retained = false,
|
retained = false,
|
||||||
@@ -66,11 +59,7 @@ export function UsageBalanceBar({
|
|||||||
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
||||||
const tone = balance.tone ?? "default";
|
const tone = balance.tone ?? "default";
|
||||||
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
||||||
|
const resetTime = formatResetTime(balance.resetsAt, now, t);
|
||||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
|
||||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
|
||||||
[usedPct, tone],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -78,22 +67,21 @@ export function UsageBalanceBar({
|
|||||||
<Text style={styles.label} numberOfLines={1}>
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
|
<Text
|
||||||
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
|
style={styles.amount}
|
||||||
{amountText}
|
numberOfLines={1}
|
||||||
</Text>
|
ellipsizeMode="tail"
|
||||||
{resetLabel ? (
|
testID={`provider-usage-balance-${balance.id}-value`}
|
||||||
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
|
>
|
||||||
{resetLabel}
|
{amountText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{usedPct != null ? (
|
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
|
||||||
<View style={styles.track}>
|
<UsageResetLine
|
||||||
<View style={fillStyle} />
|
countdown={resetLabel}
|
||||||
</View>
|
exactTime={resetTime}
|
||||||
) : null}
|
testID={`provider-usage-balance-${balance.id}-reset`}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
flexShrink: 1,
|
|
||||||
alignItems: "flex-end",
|
|
||||||
},
|
|
||||||
amount: {
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
reset: {
|
amount: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
fillDefault: {
|
|
||||||
backgroundColor: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
fillOk: {
|
|
||||||
backgroundColor: theme.colors.statusSuccess,
|
|
||||||
},
|
|
||||||
fillWarning: {
|
|
||||||
backgroundColor: theme.colors.statusWarning,
|
|
||||||
},
|
|
||||||
fillDanger: {
|
|
||||||
backgroundColor: theme.colors.statusDanger,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
formatPct,
|
formatPct,
|
||||||
formatProviderUsageLabel,
|
formatProviderUsageLabel,
|
||||||
formatResetLabel,
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
formatRunsOutLabel,
|
formatRunsOutLabel,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
|
|
||||||
@@ -36,8 +37,8 @@ describe("formatAmount", () => {
|
|||||||
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
expect(formatPct(7, "en")).toBe("7%");
|
expect(formatPct(7, "en")).toBe("7%");
|
||||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
|
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
|
||||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
|
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
|
||||||
|
|
||||||
await i18n.changeLanguage("ja");
|
await i18n.changeLanguage("ja");
|
||||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
||||||
@@ -94,6 +95,70 @@ describe("formatAmount", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reset countdown", () => {
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
|
||||||
|
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
|
||||||
|
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says two units, dropping a unit that is zero", () => {
|
||||||
|
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
|
||||||
|
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
|
||||||
|
expect(countdown(HOUR)).toBe("resets in 1h");
|
||||||
|
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
|
||||||
|
expect(countdown(59 * 1000)).toBe("resets in 0m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins the at-risk countdown the same way", () => {
|
||||||
|
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
|
||||||
|
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exact reset time", () => {
|
||||||
|
// Local-time dates, so the thresholds hold in any timezone the tests run in.
|
||||||
|
const now = new Date(2026, 9, 29, 12, 0).getTime();
|
||||||
|
const at = (...args: [number, number, number, number, number]) =>
|
||||||
|
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the time within 24 hours", () => {
|
||||||
|
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
|
||||||
|
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the weekday within 7 days", () => {
|
||||||
|
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
|
||||||
|
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the date after 7 days", () => {
|
||||||
|
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the weekday in the app language", async () => {
|
||||||
|
await i18n.changeLanguage("fr");
|
||||||
|
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing for a time that has passed", () => {
|
||||||
|
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
|
||||||
|
expect(formatResetTime(null, now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
||||||
const past = "2000-01-01T00:00:00.000Z";
|
const past = "2000-01-01T00:00:00.000Z";
|
||||||
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
import { formatTimeOfDay } from "@/utils/time";
|
||||||
import type { UsageDisplayAs } from "./preferences";
|
import type { UsageDisplayAs } from "./preferences";
|
||||||
import type { UsageBalanceUnit } from "./types";
|
import type { UsageBalanceUnit } from "./types";
|
||||||
|
|
||||||
@@ -50,7 +51,17 @@ export function formatDisplayPct(
|
|||||||
: t("providerUsage.values.remaining", { amount: percentage });
|
: t("providerUsage.values.remaining", { amount: percentage });
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
|
type DurationUnit = "minutes" | "hours" | "days";
|
||||||
|
interface DurationPart {
|
||||||
|
unit: DurationUnit;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
|
||||||
|
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
|
||||||
|
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
function formatCount(value: number): string {
|
function formatCount(value: number): string {
|
||||||
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
||||||
@@ -59,35 +70,34 @@ function formatCount(value: number): string {
|
|||||||
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
||||||
const diffMs = new Date(iso).getTime() - now;
|
const diffMs = new Date(iso).getTime() - now;
|
||||||
if (!Number.isFinite(diffMs)) return null;
|
if (!Number.isFinite(diffMs)) return null;
|
||||||
if (diffMs <= 0) return { unit: "now" };
|
if (diffMs <= 0) return { kind: "now" };
|
||||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
const days = Math.floor(diffMs / DAY_MS);
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
|
||||||
if (diffDays > 0) return { unit: "days", count: diffDays };
|
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
|
||||||
if (diffHours > 0) return { unit: "hours", count: diffHours };
|
if (hours > 0)
|
||||||
return { unit: "minutes", count: diffMinutes };
|
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
|
||||||
|
return remaining({ unit: "minutes", count: minutes });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
|
/** The major unit, plus the minor one unless it is zero. */
|
||||||
switch (duration.unit) {
|
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
|
||||||
case "now":
|
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
|
||||||
return null;
|
}
|
||||||
case "days":
|
|
||||||
return t("providerUsage.duration.days", {
|
function formatDurationPart(part: DurationPart, t: TFunction): string {
|
||||||
count: duration.count,
|
return t(`providerUsage.duration.${part.unit}`, {
|
||||||
value: formatCount(duration.count),
|
count: part.count,
|
||||||
});
|
value: formatCount(part.count),
|
||||||
case "hours":
|
});
|
||||||
return t("providerUsage.duration.hours", {
|
}
|
||||||
count: duration.count,
|
|
||||||
value: formatCount(duration.count),
|
function formatRelativeDuration(
|
||||||
});
|
duration: Extract<RelativeDuration, { kind: "remaining" }>,
|
||||||
case "minutes":
|
t: TFunction,
|
||||||
return t("providerUsage.duration.minutes", {
|
): string {
|
||||||
count: duration.count,
|
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
|
||||||
value: formatCount(duration.count),
|
return second ? t("providerUsage.duration.pair", { first, second }) : first;
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatResetLabel(
|
export function formatResetLabel(
|
||||||
@@ -99,10 +109,34 @@ export function formatResetLabel(
|
|||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const rel = relativeDuration(iso, now);
|
const rel = relativeDuration(iso, now);
|
||||||
if (!rel) return null;
|
if (!rel) return null;
|
||||||
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||||
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
|
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
|
||||||
const duration = formatRelativeDuration(rel, t);
|
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
|
||||||
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
|
||||||
|
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
|
||||||
|
* Nothing for a time that has passed, where the countdown already says so.
|
||||||
|
*/
|
||||||
|
export function formatResetTime(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
now = Date.now(),
|
||||||
|
t: TFunction = i18n.t,
|
||||||
|
): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const at = new Date(iso);
|
||||||
|
const diffMs = at.getTime() - now;
|
||||||
|
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
|
||||||
|
const time = formatTimeOfDay(at);
|
||||||
|
const locale = i18n.resolvedLanguage;
|
||||||
|
if (diffMs < DAY_MS) return time;
|
||||||
|
if (diffMs < 7 * DAY_MS) {
|
||||||
|
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
|
||||||
|
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
|
||||||
|
}
|
||||||
|
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||||||
|
return t("providerUsage.timing.resetsAtDate", { date, time });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRunsOutLabel(
|
export function formatRunsOutLabel(
|
||||||
@@ -113,9 +147,8 @@ export function formatRunsOutLabel(
|
|||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const rel = relativeDuration(iso, now);
|
const rel = relativeDuration(iso, now);
|
||||||
if (!rel) return null;
|
if (!rel) return null;
|
||||||
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
|
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
|
||||||
const duration = formatRelativeDuration(rel, t);
|
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
|
||||||
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAgo(
|
export function formatAgo(
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function UsageMeter({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
track: {
|
track: {
|
||||||
height: 4,
|
height: 6,
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reset data of a window or balance: the countdown first, then the exact time. When the line
|
||||||
|
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
|
||||||
|
*/
|
||||||
|
export function UsageResetLine({
|
||||||
|
countdown,
|
||||||
|
exactTime,
|
||||||
|
atRisk = false,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
countdown: string | null;
|
||||||
|
exactTime: string | null;
|
||||||
|
/** The countdown is a runs-out warning rather than a reset. */
|
||||||
|
atRisk?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
if (!countdown) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.line} testID={testID}>
|
||||||
|
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
|
||||||
|
{exactTime ? (
|
||||||
|
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{`\u00a0·\u00a0${exactTime}`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
line: { flexDirection: "row", alignItems: "center" },
|
||||||
|
text: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
atRisk: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.colors.statusDanger,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
exactTime: { flexShrink: 1 },
|
||||||
|
}));
|
||||||
@@ -192,12 +192,7 @@ function PinnedUsageLine({
|
|||||||
<Fragment key={window.key}>
|
<Fragment key={window.key}>
|
||||||
{layout === "meters" ? (
|
{layout === "meters" ? (
|
||||||
<View style={[styles.meterSlot, leadStyle]}>
|
<View style={[styles.meterSlot, leadStyle]}>
|
||||||
<UsageMeter
|
<UsageMeter percent={window.percent} tone={window.tone} quiet />
|
||||||
percent={window.percent}
|
|
||||||
tone={window.tone}
|
|
||||||
quiet
|
|
||||||
style={styles.meter}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<Text
|
<Text
|
||||||
@@ -269,10 +264,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: MIN_METER_WIDTH,
|
minWidth: MIN_METER_WIDTH,
|
||||||
marginRight: METER_GAP,
|
marginRight: METER_GAP,
|
||||||
},
|
},
|
||||||
meter: {
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
},
|
|
||||||
percent: {
|
percent: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
import { Pin } from "lucide-react-native";
|
import { Pin } from "lucide-react-native";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
formatDisplayPct,
|
formatDisplayPct,
|
||||||
formatProviderUsageLabel,
|
formatProviderUsageLabel,
|
||||||
formatResetLabel,
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
formatRunsOutLabel,
|
formatRunsOutLabel,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
import { UsageMeter } from "./meter";
|
import { UsageMeter } from "./meter";
|
||||||
import { displayPercent, usageWindowRowLabel } from "./model";
|
import { displayPercent, usageWindowRowLabel } from "./model";
|
||||||
|
import { UsageResetLine } from "./reset-line";
|
||||||
import type { UsageDisplayAs } from "./preferences";
|
import type { UsageDisplayAs } from "./preferences";
|
||||||
import { windowTone } from "./tone";
|
import { windowTone } from "./tone";
|
||||||
import type { UsageTone, UsageWindow } from "./types";
|
import type { UsageTone, UsageWindow } from "./types";
|
||||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||||
|
|
||||||
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
|
|
||||||
// so a hover never reads as the selection. Pinned rows do not react to hover.
|
|
||||||
function highlightStyle(pinned: boolean, hovered: boolean) {
|
|
||||||
if (pinned) return styles.highlightPinned;
|
|
||||||
return hovered ? styles.highlightHovered : styles.highlightNone;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsageWindowBar({
|
export function UsageWindowBar({
|
||||||
window,
|
window,
|
||||||
displayAs,
|
displayAs,
|
||||||
@@ -54,9 +49,12 @@ export function UsageWindowBar({
|
|||||||
window.runsOutAt != null &&
|
window.runsOutAt != null &&
|
||||||
window.shortfallPct != null &&
|
window.shortfallPct != null &&
|
||||||
(!retained || Date.parse(window.runsOutAt) > now);
|
(!retained || Date.parse(window.runsOutAt) > now);
|
||||||
const trailing = isAtRisk
|
const countdown = isAtRisk
|
||||||
? formatRunsOutLabel(window.runsOutAt, now, t)
|
? formatRunsOutLabel(window.runsOutAt, now, t)
|
||||||
: formatResetLabel(window.resetsAt, retained, now, t);
|
: formatResetLabel(window.resetsAt, retained, now, t);
|
||||||
|
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
|
||||||
|
// the quota runs out.
|
||||||
|
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
|
||||||
|
|
||||||
const value =
|
const value =
|
||||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
||||||
@@ -64,60 +62,57 @@ export function UsageWindowBar({
|
|||||||
const content = {
|
const content = {
|
||||||
label: formatProviderUsageLabel(window.id, window.label, t),
|
label: formatProviderUsageLabel(window.id, window.label, t),
|
||||||
value,
|
value,
|
||||||
trailing,
|
countdown,
|
||||||
|
exactTime,
|
||||||
isAtRisk,
|
isAtRisk,
|
||||||
percent: shownPct ?? 0,
|
percent: shownPct ?? 0,
|
||||||
tone,
|
tone,
|
||||||
pinned,
|
pinned,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Same padding as the pinnable row, so bars line up in both modes.
|
|
||||||
if (!pinnable) {
|
if (!pinnable) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
|
<WindowRowContent {...content} pinnable={false} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
|
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
|
||||||
// padding so toggling only changes the background.
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onTogglePin}
|
onPress={onTogglePin}
|
||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
|
accessibilityLabel={usageWindowRowLabel({
|
||||||
|
pinLabel,
|
||||||
|
value,
|
||||||
|
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
|
||||||
|
})}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-checked={pinned}
|
aria-checked={pinned}
|
||||||
style={styles.row}
|
style={styles.row}
|
||||||
testID={pinTestID}
|
testID={pinTestID}
|
||||||
>
|
>
|
||||||
{({ hovered }: { hovered?: boolean }) => (
|
<WindowRowContent {...content} pinnable />
|
||||||
<WindowRowContent
|
|
||||||
{...content}
|
|
||||||
highlight={highlightStyle(pinned, Boolean(hovered))}
|
|
||||||
pinnable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WindowRowContent({
|
function WindowRowContent({
|
||||||
highlight,
|
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
trailing,
|
countdown,
|
||||||
|
exactTime,
|
||||||
isAtRisk,
|
isAtRisk,
|
||||||
percent,
|
percent,
|
||||||
tone,
|
tone,
|
||||||
pinnable,
|
pinnable,
|
||||||
pinned,
|
pinned,
|
||||||
}: {
|
}: {
|
||||||
highlight: StyleProp<ViewStyle>;
|
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
trailing: string | null | undefined;
|
countdown: string | null;
|
||||||
|
exactTime: string | null;
|
||||||
isAtRisk: boolean;
|
isAtRisk: boolean;
|
||||||
percent: number;
|
percent: number;
|
||||||
tone: UsageTone;
|
tone: UsageTone;
|
||||||
@@ -126,24 +121,17 @@ function WindowRowContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={highlight} pointerEvents="none" />
|
<View style={styles.labelRow}>
|
||||||
<View style={styles.contentRow}>
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
<View style={styles.windowContent}>
|
{label}
|
||||||
<View style={styles.labelRow}>
|
</Text>
|
||||||
<Text style={styles.label} numberOfLines={1}>
|
<Text style={styles.value} numberOfLines={1}>
|
||||||
{label}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{value}
|
|
||||||
{trailing ? (
|
|
||||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
|
||||||
) : null}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<UsageMeter percent={percent} tone={tone} />
|
|
||||||
</View>
|
|
||||||
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
||||||
</View>
|
</View>
|
||||||
|
<UsageMeter percent={percent} tone={tone} />
|
||||||
|
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin);
|
|||||||
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconMapping = useMemo(
|
const iconMapping = useMemo(
|
||||||
() => (theme: { colors: { foregroundMuted: string } }) => ({
|
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||||
fill: pinned ? theme.colors.foregroundMuted : "none",
|
fill: pinned ? theme.colors.foreground : "none",
|
||||||
}),
|
}),
|
||||||
[pinned],
|
[pinned],
|
||||||
);
|
);
|
||||||
@@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
|
||||||
windowContent: { flex: 1, gap: 3 },
|
|
||||||
pin: { width: 12, alignItems: "center" },
|
pin: { width: 12, alignItems: "center" },
|
||||||
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
||||||
row: {
|
|
||||||
gap: 3,
|
|
||||||
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
|
|
||||||
marginHorizontal: -theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1.5],
|
|
||||||
// Its own stacking context, so the highlight layer paints above the card and below the text.
|
|
||||||
zIndex: 0,
|
|
||||||
},
|
|
||||||
highlightNone: {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
highlightPinned: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
zIndex: -1,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
|
|
||||||
highlightHovered: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
zIndex: -1,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
labelRow: {
|
labelRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
reset: {
|
value: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexShrink: 0,
|
||||||
fontWeight: theme.fontWeight.normal,
|
color: theme.colors.foreground,
|
||||||
},
|
fontSize: theme.fontSize.sm,
|
||||||
atRisk: {
|
|
||||||
color: theme.colors.statusDanger,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
|||||||
return cachedTimeFormatter;
|
return cachedTimeFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
|
||||||
|
export function formatTimeOfDay(date: Date): string {
|
||||||
|
return getTimeFormatter().format(date);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a chat-message timestamp for hover-revealed UI.
|
* Format a chat-message timestamp for hover-revealed UI.
|
||||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||||
@@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
|||||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||||
*/
|
*/
|
||||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||||
const time = getTimeFormatter().format(date);
|
const time = formatTimeOfDay(date);
|
||||||
const daysAgo = localCalendarDaysBetween(date, now);
|
const daysAgo = localCalendarDaysBetween(date, now);
|
||||||
|
|
||||||
if (daysAgo === 0) {
|
if (daysAgo === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user