Files
accounted/extensions/general/cloud-backup/components/CloudBackupCard.tsx
T
Jakob Wennberg d41ef2a909 feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs

The sandbox showed neither Löner nor a usable set of reports, and the
"connect X" surfaces were oversized boxed cards.

Sandbox seed:
- pays_salaries + employer_registered, so Löner and Anställda appear at all
  (an enskild firma is not an employer by default). Both seeded employees are
  employment_type 'employee': an EF may employ staff, just not its own owner.
- Two employees, one booked and one open lönekörning, and the three verifikat
  the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/
  2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables.
- Year-to-date ledger history, January through last month, with the quarterly
  momsredovisning cleared to 2650 and paid on the SFL deadline. Without the
  settlement the demo collected VAT all year and never remitted it, which left
  an implausible bank balance and 155 813 kr of moms "att betala".
- The history is exempted through journal_entry_no_doc_required, the same way
  the SIE-import opt-in treats imported books: its kvitton live in the previous
  system, and unflagged it put 39 "verifikat utan underlag" on the home screen.
- Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma.
- History is numbered before the invoice and payroll vouchers so the series runs
  forwards through the year, and its writes are batched.

Connect CTAs:
- Bank picker: a two-column grid of 95px bordered logo cards becomes flat
  hairline rows, Lucide icons, and a quiet inline connecting state.
- Cloud backup: each provider collapses to one row; the BFL note is shown once
  for the section and names only configured destinations.
- Hem first-run: only the active step argues its case, but every not-done step
  keeps a reachable action. The Skatteverket nudge becomes one quiet sentence.

Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already
has an Assistent tab. A collapsed session keeps its handle everywhere except
/chat, which is itself the way back to the conversation.

Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox
guard, and a seeded booked run put "Skicka lönebesked" one click from an
anonymous visitor with live Resend behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): check the two unchecked Supabase errors and tighten review nits

CodeRabbit review on #1437.

Major: two calls discarded their error and continued with null data. A failed
chart_of_accounts re-select would have written account_id: null onto every
ledger-history and salary voucher line, and a failed next_voucher_number would
have inserted a posted verifikat with no number, which is a hole in the
verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is
rejected explicitly.

Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the
salary breakdown test's name said the opposite of its assertions after the
switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat
per month before the quarterly momsredovisning added a seventh in March, May and
June.

Bank picker: the spinner is aria-hidden, so loading and connecting had no text
equivalent and a failed bank fetch was never announced. Added role="status" with
an sr-only label, and role="alert" on the error line.

Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was
unconfirmed before this PR too, so adding a dialog is a behaviour change beyond
the redesign rather than a fix to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:04 +02:00

733 lines
25 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AttnLine } from '@/components/ui/attn-line'
import {
SettingsRow,
SettingsRowEnd,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import type {
CloudBackupStatus,
CloudLastSync,
CloudProviderId,
CloudProviderStatus,
CloudSchedule,
} from '../types'
const API_BASE = '/api/extensions/ext/cloud-backup'
/**
* Provider presentation. The label is the brand name and stays untranslated in
* both locales; everything around it is a `{provider}` placeholder so the
* copy reads naturally whichever destination the row describes.
*/
const PROVIDER_META: Record<CloudProviderId, { label: string; icon: typeof Cloud }> = {
google_drive: { label: 'Google Drive', icon: Cloud },
dropbox: { label: 'Dropbox', icon: Box },
}
export default function CloudBackupCard() {
const t = useTranslations('extensions')
const { toast } = useToast()
const searchParams = useSearchParams()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/status`)
if (!res.ok) throw new Error(t('ext_cloud_backup_status_failed'))
const { data } = (await res.json()) as { data: CloudBackupStatus }
setStatus(data)
} finally {
setIsLoading(false)
}
}, [t])
useEffect(() => {
loadStatus()
}, [loadStatus])
// After a connect redirect the first backup builds in the background: poll
// the status a few times so the finished sync shows up without a reload.
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
// Handle OAuth callback redirect params. `provider` tells us which row the
// user just came back from; absent means a redirect issued before Dropbox
// existed, which can only have been Google Drive.
useEffect(() => {
const result = searchParams.get('cloud_backup')
if (!result) return
const providerId = (searchParams.get('provider') as CloudProviderId) || 'google_drive'
const providerLabel = PROVIDER_META[providerId]?.label ?? providerId
if (result === 'connected' || result === 'connected_first') {
toast({
title: t('ext_cloud_backup_connected_title', { provider: providerLabel }),
description: t(
result === 'connected_first'
? 'ext_cloud_backup_connected_first_description'
: 'ext_cloud_backup_connected_description'
),
})
let attempts = 0
pollRef.current = setInterval(() => {
attempts += 1
loadStatus()
if (attempts >= 6 && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}, 10_000)
} else if (result === 'error') {
const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error')
toast({
title: t('ext_cloud_backup_connect_failed', { provider: providerLabel }),
description: reason,
variant: 'destructive',
})
}
// Clean the URL so refresh doesn't re-fire the toast.
const url = new URL(window.location.href)
url.searchParams.delete('cloud_backup')
url.searchParams.delete('provider')
url.searchParams.delete('reason')
window.history.replaceState({}, '', url.toString())
}, [loadStatus, searchParams, t, toast])
if (isLoading) {
return (
<p className="px-1 py-3 text-xs text-muted-foreground">
{t('ext_cloud_backup_loading')}
</p>
)
}
const providers = status?.providers ?? []
// Configured too: reconnecting a destination whose credentials are gone
// would only bounce off `/connect`, so that row keeps its quiet
// "not configured" line instead.
const reauthProviders = providers.filter((p) => p.configured && p.needs_reauth)
// `/status` returns a row for every provider the build knows about, whether
// or not this deployment has credentials for it, so the note has to filter:
// naming Dropbox where Dropbox is not configured would promise a
// destination that does not exist. Configured (not connected) is the right
// cut, because the note is the compliance framing for the decision to
// connect and has to read before anything is connected.
const destinations = providers
.filter((p) => p.configured)
.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
.join(' / ')
return (
<div>
{reauthProviders.length > 0 && <ReauthAttn providers={reauthProviders} />}
<div>
{providers.map((providerStatus) => (
<ProviderRow
key={providerStatus.provider}
status={providerStatus}
onChanged={loadStatus}
/>
))}
</div>
{/*
The 7-year BFL note is compliance context, so it stays visible rather
than hiding behind hover: it is shown once for the whole section
instead of once per destination, because the statement is identical
for every provider and repeating it was what made the panel bulky.
*/}
{destinations.length > 0 && (
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">
{t('ext_cloud_backup_legal_note', { provider: destinations })}
</p>
)}
</div>
)
}
/**
* Start the OAuth handshake for one destination. Shared by the connect button
* on a row and by the section-level reconnect line, so both take the exact
* same path.
*/
function useConnectProvider(providerId: CloudProviderId, provider: string) {
const { toast } = useToast()
const t = useTranslations('extensions')
const [isConnecting, setIsConnecting] = useState(false)
const connect = useCallback(async () => {
// The reconnect affordance is a plain inline link (no disabled state), so
// the guard lives here instead of on a button.
if (isConnecting) return
setIsConnecting(true)
try {
const res = await fetch(
`${API_BASE}/connect?provider=${encodeURIComponent(providerId)}`,
{ method: 'POST' }
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
}
const { url } = (await res.json()) as { url: string }
window.location.href = url
} catch (err) {
toast({
title: t('ext_cloud_backup_connect_failed', { provider }),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
setIsConnecting(false)
}
}, [isConnecting, provider, providerId, t, toast])
return { isConnecting, connect }
}
/**
* Attention is one ochre sentence, max one per section (convention 6), so the
* reconnect notice lives here and not on each row: both destinations can lose
* their token at once, and two stacked banners is exactly the shape the
* convention forbids. The sentence names every affected destination; the
* action reconnects the first, since one redirect can only hand off to one
* provider. Once it is back the line returns for the next one.
*/
function ReauthAttn({ providers }: { providers: CloudProviderStatus[] }) {
const t = useTranslations('extensions')
const labels = providers.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
const { isConnecting, connect } = useConnectProvider(providers[0].provider, labels[0])
return (
<AttnLine
className="pb-3"
action={{
label: isConnecting
? t('ext_cloud_backup_redirecting')
: t('ext_cloud_backup_reauth_action', { provider: labels[0] }),
onClick: connect,
}}
>
{t('ext_cloud_backup_reauth_description', { provider: labels.join(' / ') })}
</AttnLine>
)
}
interface ProviderRowProps {
status: CloudProviderStatus
onChanged: () => Promise<void> | void
}
/**
* One destination as a single hairline row: brand mark and name on the left,
* the connect action on the right. Connected destinations unfold their detail
* (last sync, schedule) as indented settings rows below the same header row.
* Every request carries `?provider=`, so the two rows never touch each
* other's records. The reconnect notice is deliberately not here: it belongs
* to the section (see `ReauthAttn`), one ochre sentence for all destinations.
*/
function ProviderRow({ status, onChanged }: ProviderRowProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
const { dialogProps, confirm } = useDestructiveConfirm()
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const providerId = status.provider
const meta = PROVIDER_META[providerId]
const provider = meta?.label ?? providerId
const Icon = meta?.icon ?? Cloud
const qs = `?provider=${encodeURIComponent(providerId)}`
const { isConnecting, connect: handleConnect } = useConnectProvider(providerId, provider)
const handleDisconnect = useCallback(async () => {
setIsDisconnecting(true)
try {
const res = await fetch(`${API_BASE}/disconnect${qs}`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_disconnect_failed'))
}
toast({ title: t('ext_cloud_backup_disconnected', { provider }) })
await onChanged()
} catch (err) {
toast({
title: t('ext_cloud_backup_disconnect_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsDisconnecting(false)
}
}, [onChanged, provider, qs, t, toast])
type SyncOutcome =
| { result: 'ok' | 'error' }
| { result: 'too_large'; sizeMb: number | null; limitMb: number | null }
const syncOnce = useCallback(
async (allowDocumentFallback: boolean): Promise<SyncOutcome> => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_documents: true,
allow_document_fallback: allowDocumentFallback,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
if (res.status === 413 && !allowDocumentFallback) {
// Handled by the caller: a dialog offers syncing the oversized
// archives without their document blobs.
return {
result: 'too_large',
sizeMb: body.size_bytes
? Math.round(body.size_bytes / (1024 * 1024))
: null,
limitMb: body.size_limit_bytes
? Math.round(body.size_limit_bytes / (1024 * 1024))
: null,
}
}
if (body.error === 'needs_reauth') {
// Refresh status so the row switches to the reconnect state.
await onChanged()
throw new Error(t('ext_cloud_backup_reauth_description', { provider }))
}
throw new Error(body.error || t('ext_cloud_backup_sync_failed'))
}
const { data } = (await res.json()) as {
data: CloudLastSync & {
web_view_link: string
uploaded_count?: number
skipped_count?: number
}
}
if (data.uploaded_count === 0) {
toast({
title: t('ext_cloud_backup_up_to_date'),
description: t('ext_cloud_backup_no_changes'),
})
} else {
const anyNoDocs = (data.files ?? []).some(
(f) => f.kind !== 'readme' && f.included_documents === false
)
toast({
title: t('ext_cloud_backup_uploaded', { provider }),
description: `${t('ext_cloud_backup_files_updated', {
count: data.uploaded_count ?? 0,
})} (${formatMb(data.total_size_bytes ?? data.file_size_bytes ?? 0)})${
anyNoDocs ? ` · ${t('ext_cloud_backup_no_documents_note')}` : ''
}`,
})
}
await onChanged()
return { result: 'ok' }
} catch (err) {
toast({
title: t('ext_cloud_backup_sync_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
return { result: 'error' }
} finally {
setIsSyncing(false)
}
},
[onChanged, provider, qs, t, toast]
)
const handleSync = useCallback(async () => {
const first = await syncOnce(false)
if (first.result !== 'too_large') return
const ok = await confirm({
title: t('ext_cloud_backup_too_large_title'),
description: t('ext_cloud_backup_too_large_description', {
size: first.sizeMb != null ? String(first.sizeMb) : '?',
limit: first.limitMb != null ? String(first.limitMb) : '?',
}),
confirmLabel: t('ext_cloud_backup_too_large_confirm'),
variant: 'warning',
})
if (ok) await syncOnce(true)
}, [confirm, syncOnce, t])
// The scope copy ("only files the app creates" / "its own app folder") is
// what earns the OAuth grant, so it stays visible in the state where the
// user decides: it replaces the tagline, which only paraphrased the row
// title. Connected rows swap it for the account the backup lands in.
const scopeKey =
providerId === 'dropbox'
? 'ext_cloud_backup_connect_description_dropbox'
: 'ext_cloud_backup_connect_description_google'
const supportingLine = status.connected
? status.account_email
: status.configured
? t(scopeKey)
: null
return (
<div className="border-b border-border">
<DestructiveConfirmDialog {...dialogProps} />
{/* Header row: identity left, action right, in every state. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-3">
<div className="flex min-w-0 items-center gap-3">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<span className="text-sm font-medium">{provider}</span>
{supportingLine && (
<p
className={cn(
'mt-1 text-xs leading-5 text-muted-foreground',
// Only the account address is a single unbreakable token
// worth clipping; the scope sentence has to wrap, or it is
// cut off on a 390px screen where the row does not wrap.
status.connected && 'truncate'
)}
>
{supportingLine}
</p>
)}
</div>
</div>
{!status.configured ? (
// Not configured is a state, not an action: one quiet line where the
// button would sit, free to wrap on narrow screens.
<span className="text-xs text-muted-foreground">
{t('ext_cloud_backup_not_configured', { provider })}
</span>
) : (
<div className="flex shrink-0 items-center gap-2">
{status.connected ? (
<>
<Button variant="outline" onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_syncing')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_sync_now')}
</>
)}
</Button>
<Button variant="ghost" onClick={handleDisconnect} disabled={isDisconnecting}>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_disconnecting')}
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_disconnect')}
</>
)}
</Button>
</>
) : (
<Button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_redirecting')}
</>
) : (
t('ext_cloud_backup_connect', { provider })
)}
</Button>
)}
</div>
)}
</div>
{/*
Both gates, not just `connected`: a deployment that loses its
credentials while an account is still stored shows the "not
configured" state alone, exactly as the header row does, instead of
offering controls next to a line saying the destination is unavailable.
`role="group"` ties these controls to the destination they belong to:
with two rows open, "Synka nu" is otherwise unattributable.
*/}
{status.configured && status.connected && (
<div
role="group"
aria-label={provider}
className="ml-3 border-l border-border pb-3 pl-4"
>
<SettingsRow label={t('ext_cloud_backup_last_sync_label')} align="baseline">
{status.last_sync ? (
<LastSyncSummary lastSync={status.last_sync} providerId={providerId} />
) : (
<span className="text-muted-foreground">{t('ext_cloud_backup_never')}</span>
)}
</SettingsRow>
<ScheduleSection
providerId={providerId}
provider={provider}
schedule={status.schedule}
needsReauth={status.needs_reauth}
onUpdated={onChanged}
/>
</div>
)}
</div>
)
}
/**
* Last-sync cell. Records written since the Dropbox target landed carry their
* own `web_view_link`; older Drive records only have a folder id, so the Drive
* URL is reconstructed. Legacy single-ZIP records link to the file itself.
*/
function LastSyncSummary({
lastSync,
providerId,
}: {
lastSync: CloudLastSync
providerId: CloudProviderId
}) {
const t = useTranslations('extensions')
const files = lastSync.files
const href =
lastSync.web_view_link ??
(providerId === 'google_drive'
? files
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
: 'https://www.dropbox.com/home/Apps')
const sizeBytes = files
? lastSync.total_size_bytes ?? 0
: lastSync.file_size_bytes ?? 0
const anyNoDocs = files
? files.some((f) => f.kind !== 'readme' && f.included_documents === false)
: lastSync.included_documents === false
const archiveCount = files ? files.filter((f) => f.kind !== 'readme').length : null
const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256)
return (
<div className="min-w-0">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
>
{formatDateTime(lastSync.at)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
<p className="text-xs text-muted-foreground tabular-nums">
{formatMb(sizeBytes)}
{archiveCount !== null &&
` · ${t('ext_cloud_backup_files_count', { count: archiveCount })}`}
{verified && ` · ${t('ext_cloud_backup_verified')}`}
</p>
{anyNoDocs && (
<p className="text-xs text-muted-foreground">
{t('ext_cloud_backup_last_sync_no_documents')}
</p>
)}
</div>
)
}
function formatMb(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
interface ScheduleSectionProps {
providerId: CloudProviderId
provider: string
schedule: CloudSchedule | null
needsReauth: boolean
onUpdated: () => Promise<void> | void
}
function ScheduleSection({
providerId,
provider,
schedule,
needsReauth,
onUpdated,
}: ScheduleSectionProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
// Prefer the DST-stable Stockholm hour; fall back to converting the legacy
// UTC hour through the browser's clock (Swedish users: same thing).
const scheduleHour = (s: CloudSchedule | null): number =>
typeof s?.hour_local === 'number'
? s.hour_local
: utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3)
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
const [isSaving, setIsSaving] = useState(false)
// Each provider renders its own controls, so the id must not collide.
const hourId = `auto-sync-hour-${providerId}`
useEffect(() => {
setEnabled(schedule?.enabled ?? false)
setLocalHour(scheduleHour(schedule))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule?.enabled, schedule?.hour_utc, schedule?.hour_local])
const save = useCallback(
async (nextEnabled: boolean, nextLocalHour: number) => {
setIsSaving(true)
try {
const res = await fetch(
`${API_BASE}/schedule?provider=${encodeURIComponent(providerId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: nextEnabled,
hour_local: nextLocalHour,
}),
}
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed'))
}
await onUpdated()
} catch (err) {
toast({
title: t('ext_cloud_backup_schedule_save_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
},
[onUpdated, providerId, t, toast]
)
const handleToggle = useCallback(
(checked: boolean) => {
setEnabled(checked)
save(checked, localHour)
},
[localHour, save]
)
const handleHourChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const next = Number(e.target.value)
setLocalHour(next)
if (enabled) save(enabled, next)
},
[enabled, save]
)
return (
<>
<SettingsRow
label={t('ext_cloud_backup_auto_sync_title')}
help={t('ext_cloud_backup_auto_sync_description', { provider })}
borderless={!enabled}
>
<SettingsRowEnd>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isSaving}
aria-label={t('ext_cloud_backup_auto_sync_title')}
/>
</SettingsRowEnd>
</SettingsRow>
{enabled && (
<SettingsRow label={t('ext_cloud_backup_time_label')} htmlFor={hourId} borderless>
<SettingsSelect
id={hourId}
value={localHour}
onChange={handleHourChange}
disabled={isSaving}
className="tabular-nums"
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{h.toString().padStart(2, '0')}:00
</option>
))}
</SettingsSelect>
{isSaving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</SettingsRow>
)}
{schedule?.last_auto_sync_at && (
<p className="px-1 text-xs text-muted-foreground">
{t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '}
{schedule.last_auto_sync_status === 'success' ? (
<span className="text-success">· {t('ext_cloud_backup_auto_sync_success')}</span>
) : schedule.last_auto_sync_status === 'error' ? (
<span className="text-destructive">
· {t('ext_cloud_backup_auto_sync_error')}
{needsReauth
? ` (${t('ext_cloud_backup_reauth_needed_short')})`
: schedule.last_auto_sync_error
? ` (${schedule.last_auto_sync_error})`
: ''}
</span>
) : null}
</p>
)}
</>
)
}
/** Convert a UTC hour (0-23) to the browser's local hour. */
function utcHourToLocalHour(hourUtc: number): number {
const d = new Date()
d.setUTCHours(hourUtc, 0, 0, 0)
return d.getHours()
}