Skip to main content

communitas_ui_api/
sync.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Sync state types for offline-first UX indicators.
4//!
5//! This module provides types for tracking and displaying synchronization
6//! state across messaging, drive, and kanban surfaces.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11/// Synchronization state for an item or collection.
12///
13/// Used to indicate whether content is fully synced, currently syncing,
14/// queued for sync, or has conflicts that need resolution.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16pub enum SyncState {
17    /// Content is fully synchronized with peers.
18    #[default]
19    Synced,
20    /// Content is currently being synchronized.
21    Syncing,
22    /// Content is queued for sync (offline or pending).
23    Queued,
24    /// Content has conflicts that need resolution.
25    Conflict,
26    /// Sync failed with an error.
27    Error,
28}
29
30impl SyncState {
31    /// Returns true if this state requires user attention.
32    pub fn needs_attention(&self) -> bool {
33        matches!(self, SyncState::Conflict | SyncState::Error)
34    }
35
36    /// Returns true if sync is in progress or pending.
37    pub fn is_pending(&self) -> bool {
38        matches!(self, SyncState::Syncing | SyncState::Queued)
39    }
40
41    /// Returns the appropriate icon name for this state.
42    pub fn icon_name(&self) -> &'static str {
43        match self {
44            SyncState::Synced => "check-circle",
45            SyncState::Syncing => "refresh-cw",
46            SyncState::Queued => "clock",
47            SyncState::Conflict => "alert-triangle",
48            SyncState::Error => "x-circle",
49        }
50    }
51
52    /// Returns the appropriate color class for this state.
53    pub fn color_class(&self) -> &'static str {
54        match self {
55            SyncState::Synced => "text-green-500",
56            SyncState::Syncing => "text-blue-500",
57            SyncState::Queued => "text-orange-500",
58            SyncState::Conflict => "text-yellow-500",
59            SyncState::Error => "text-red-500",
60        }
61    }
62}
63
64impl fmt::Display for SyncState {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            SyncState::Synced => write!(f, "Synced"),
68            SyncState::Syncing => write!(f, "Syncing"),
69            SyncState::Queued => write!(f, "Waiting to sync"),
70            SyncState::Conflict => write!(f, "Has conflicts"),
71            SyncState::Error => write!(f, "Sync failed"),
72        }
73    }
74}
75
76/// Metadata about synchronization state for an item or collection.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub struct SyncMetadata {
79    /// Current sync state.
80    pub state: SyncState,
81    /// Timestamp of last successful sync (Unix millis).
82    pub last_synced: Option<u64>,
83    /// Number of pending changes waiting to sync.
84    pub pending_changes: u32,
85    /// Number of conflicts needing resolution.
86    pub conflict_count: u32,
87    /// Optional error message if state is Error.
88    pub error_message: Option<String>,
89}
90
91impl SyncMetadata {
92    /// Create new metadata with synced state.
93    pub fn synced() -> Self {
94        Self {
95            state: SyncState::Synced,
96            last_synced: Some(current_timestamp_millis()),
97            ..Default::default()
98        }
99    }
100
101    /// Create new metadata with syncing state.
102    pub fn syncing() -> Self {
103        Self {
104            state: SyncState::Syncing,
105            ..Default::default()
106        }
107    }
108
109    /// Create new metadata with queued state.
110    pub fn queued(pending_changes: u32) -> Self {
111        Self {
112            state: SyncState::Queued,
113            pending_changes,
114            ..Default::default()
115        }
116    }
117
118    /// Create new metadata with conflict state.
119    pub fn conflict(conflict_count: u32) -> Self {
120        Self {
121            state: SyncState::Conflict,
122            conflict_count,
123            ..Default::default()
124        }
125    }
126
127    /// Create new metadata with error state.
128    pub fn error(message: impl Into<String>) -> Self {
129        Self {
130            state: SyncState::Error,
131            error_message: Some(message.into()),
132            ..Default::default()
133        }
134    }
135}
136
137/// Progress information for an ongoing sync operation.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
139pub struct SyncProgress {
140    /// Total items to sync.
141    pub total: u32,
142    /// Items completed.
143    pub completed: u32,
144    /// Current item being synced (if any).
145    pub current_item: Option<String>,
146    /// Bytes transferred (for file transfers).
147    pub bytes_transferred: u64,
148    /// Total bytes to transfer.
149    pub bytes_total: u64,
150}
151
152impl SyncProgress {
153    /// Calculate progress as a percentage (0-100).
154    pub fn percentage(&self) -> u8 {
155        if self.total == 0 {
156            return 100;
157        }
158        let pct = (self.completed as f64 / self.total as f64) * 100.0;
159        pct.min(100.0) as u8
160    }
161
162    /// Calculate byte progress as a percentage (0-100).
163    pub fn bytes_percentage(&self) -> u8 {
164        if self.bytes_total == 0 {
165            return 100;
166        }
167        let pct = (self.bytes_transferred as f64 / self.bytes_total as f64) * 100.0;
168        pct.min(100.0) as u8
169    }
170
171    /// Returns true if sync is complete.
172    pub fn is_complete(&self) -> bool {
173        self.completed >= self.total
174    }
175}
176
177/// Summary of sync state across multiple items.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
179pub struct SyncSummary {
180    /// Number of items fully synced.
181    pub synced_count: u32,
182    /// Number of items currently syncing.
183    pub syncing_count: u32,
184    /// Number of items queued for sync.
185    pub queued_count: u32,
186    /// Number of items with conflicts.
187    pub conflict_count: u32,
188    /// Number of items with errors.
189    pub error_count: u32,
190}
191
192impl SyncSummary {
193    /// Returns the overall state based on item counts.
194    pub fn overall_state(&self) -> SyncState {
195        if self.error_count > 0 {
196            SyncState::Error
197        } else if self.conflict_count > 0 {
198            SyncState::Conflict
199        } else if self.syncing_count > 0 {
200            SyncState::Syncing
201        } else if self.queued_count > 0 {
202            SyncState::Queued
203        } else {
204            SyncState::Synced
205        }
206    }
207
208    /// Returns the total number of items.
209    pub fn total(&self) -> u32 {
210        self.synced_count
211            + self.syncing_count
212            + self.queued_count
213            + self.conflict_count
214            + self.error_count
215    }
216}
217
218/// Get current timestamp in milliseconds.
219fn current_timestamp_millis() -> u64 {
220    std::time::SystemTime::now()
221        .duration_since(std::time::UNIX_EPOCH)
222        .map(|d| d.as_millis() as u64)
223        .unwrap_or(0)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_sync_state_display() {
232        assert_eq!(SyncState::Synced.to_string(), "Synced");
233        assert_eq!(SyncState::Syncing.to_string(), "Syncing");
234        assert_eq!(SyncState::Queued.to_string(), "Waiting to sync");
235        assert_eq!(SyncState::Conflict.to_string(), "Has conflicts");
236        assert_eq!(SyncState::Error.to_string(), "Sync failed");
237    }
238
239    #[test]
240    fn test_sync_state_needs_attention() {
241        assert!(!SyncState::Synced.needs_attention());
242        assert!(!SyncState::Syncing.needs_attention());
243        assert!(!SyncState::Queued.needs_attention());
244        assert!(SyncState::Conflict.needs_attention());
245        assert!(SyncState::Error.needs_attention());
246    }
247
248    #[test]
249    fn test_sync_state_is_pending() {
250        assert!(!SyncState::Synced.is_pending());
251        assert!(SyncState::Syncing.is_pending());
252        assert!(SyncState::Queued.is_pending());
253        assert!(!SyncState::Conflict.is_pending());
254        assert!(!SyncState::Error.is_pending());
255    }
256
257    #[test]
258    fn test_sync_metadata_constructors() {
259        let synced = SyncMetadata::synced();
260        assert_eq!(synced.state, SyncState::Synced);
261        assert!(synced.last_synced.is_some());
262
263        let queued = SyncMetadata::queued(5);
264        assert_eq!(queued.state, SyncState::Queued);
265        assert_eq!(queued.pending_changes, 5);
266
267        let conflict = SyncMetadata::conflict(3);
268        assert_eq!(conflict.state, SyncState::Conflict);
269        assert_eq!(conflict.conflict_count, 3);
270
271        let error = SyncMetadata::error("Connection failed");
272        assert_eq!(error.state, SyncState::Error);
273        assert_eq!(error.error_message, Some("Connection failed".to_string()));
274    }
275
276    #[test]
277    fn test_sync_progress_percentage() {
278        let progress = SyncProgress {
279            total: 10,
280            completed: 5,
281            ..Default::default()
282        };
283        assert_eq!(progress.percentage(), 50);
284
285        let complete = SyncProgress {
286            total: 10,
287            completed: 10,
288            ..Default::default()
289        };
290        assert_eq!(complete.percentage(), 100);
291        assert!(complete.is_complete());
292
293        let empty = SyncProgress::default();
294        assert_eq!(empty.percentage(), 100);
295    }
296
297    #[test]
298    fn test_sync_summary_overall_state() {
299        let all_synced = SyncSummary {
300            synced_count: 10,
301            ..Default::default()
302        };
303        assert_eq!(all_synced.overall_state(), SyncState::Synced);
304
305        let has_errors = SyncSummary {
306            synced_count: 8,
307            error_count: 2,
308            ..Default::default()
309        };
310        assert_eq!(has_errors.overall_state(), SyncState::Error);
311
312        let has_conflicts = SyncSummary {
313            synced_count: 8,
314            conflict_count: 2,
315            ..Default::default()
316        };
317        assert_eq!(has_conflicts.overall_state(), SyncState::Conflict);
318
319        let some_syncing = SyncSummary {
320            synced_count: 8,
321            syncing_count: 2,
322            ..Default::default()
323        };
324        assert_eq!(some_syncing.overall_state(), SyncState::Syncing);
325    }
326}