waka_api/types.rs
1//! `WakaTime` API response types.
2//!
3//! All types derive [`Debug`], [`Clone`], [`serde::Serialize`], and
4//! [`serde::Deserialize`]. Unknown JSON fields are silently ignored via
5//! `#[serde(deny_unknown_fields)]` is **not** used — this keeps the client
6//! forward-compatible with new API fields.
7
8// The WakaTime API returns several structs with more than 3 boolean fields
9// (e.g. Goal has 5, Stats has 8). These fields mirror the upstream API
10// exactly and cannot be meaningfully replaced with enums or bitflags.
11#![allow(clippy::struct_excessive_bools)]
12
13use serde::{Deserialize, Serialize};
14
15// ─────────────────────────────────────────────────────────────────────────────
16// User
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Top-level envelope returned by `GET /users/current`.
20#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct UserResponse {
23 /// The user object.
24 pub data: User,
25}
26
27/// A `WakaTime` user account.
28#[non_exhaustive]
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct User {
31 /// Unique user identifier (UUID).
32 pub id: String,
33 /// The user's login handle.
34 pub username: String,
35 /// Human-readable display name.
36 pub display_name: String,
37 /// Full legal name (may be `None` if not set).
38 pub full_name: Option<String>,
39 /// Email address (only present when the authenticated user requests their
40 /// own profile).
41 pub email: Option<String>,
42 /// URL of the user's avatar image.
43 pub photo: Option<String>,
44 /// IANA timezone string (e.g. `"America/New_York"`).
45 pub timezone: String,
46 /// Personal website URL.
47 pub website: Option<String>,
48 /// Human-readable website URL (without protocol prefix).
49 pub human_readable_website: Option<String>,
50 /// Geographic location string.
51 pub location: Option<String>,
52 /// Subscription plan (e.g. `"free"`, `"premium"`).
53 pub plan: Option<String>,
54 /// Absolute URL of the user's public profile.
55 pub profile_url: Option<String>,
56 /// Whether the user's email address has been verified.
57 #[serde(default)]
58 pub is_email_confirmed: bool,
59 /// Whether the user is open to work.
60 pub is_hireable: Option<bool>,
61 /// Whether coding time is visible on the public profile.
62 pub logged_time_public: Option<bool>,
63 /// Whether this account is in write-only mode.
64 #[serde(default)]
65 pub writes_only: bool,
66 /// Heartbeat timeout in minutes.
67 pub timeout: Option<u32>,
68 /// Whether the user prefers 24-hour time format.
69 pub time_format_24hr: Option<bool>,
70 /// ISO 8601 timestamp when the account was created.
71 pub created_at: String,
72 /// ISO 8601 timestamp when the account was last modified.
73 pub modified_at: Option<String>,
74}
75
76// ─────────────────────────────────────────────────────────────────────────────
77// Summaries
78// ─────────────────────────────────────────────────────────────────────────────
79
80/// Top-level envelope returned by `GET /users/current/summaries`.
81#[non_exhaustive]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SummaryResponse {
84 /// Per-day summary entries (one per calendar day in the requested range).
85 pub data: Vec<SummaryData>,
86 /// ISO 8601 end of the requested range.
87 pub end: String,
88 /// ISO 8601 start of the requested range.
89 pub start: String,
90}
91
92/// Coding activity summary for a single calendar day.
93#[non_exhaustive]
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct SummaryData {
96 /// Time broken down by activity category (coding, browsing, etc.).
97 pub categories: Vec<SummaryEntry>,
98 /// Time broken down by detected dependency / library.
99 #[serde(default)]
100 pub dependencies: Vec<SummaryEntry>,
101 /// Time broken down by editor.
102 pub editors: Vec<SummaryEntry>,
103 /// Daily grand total across all activity.
104 pub grand_total: GrandTotal,
105 /// Time broken down by programming language.
106 pub languages: Vec<SummaryEntry>,
107 /// Time broken down by machine / hostname.
108 #[serde(default)]
109 pub machines: Vec<MachineEntry>,
110 /// Time broken down by operating system.
111 pub operating_systems: Vec<SummaryEntry>,
112 /// Time broken down by project.
113 pub projects: Vec<SummaryEntry>,
114 /// The date range this entry covers.
115 pub range: SummaryRange,
116}
117
118/// A single time-breakdown entry (language, project, editor, OS, etc.).
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct SummaryEntry {
121 /// Human-readable duration in `HH:MM` format.
122 pub digital: String,
123 /// Whole hours component.
124 pub hours: u32,
125 /// Whole minutes component (0–59).
126 pub minutes: u32,
127 /// Entity name (e.g. `"Python"`, `"my-project"`).
128 pub name: String,
129 /// Percentage of total time for the period (0.0–100.0).
130 pub percent: f64,
131 /// Whole seconds component (0–59).
132 pub seconds: u32,
133 /// Full human-readable duration (e.g. `"3 hrs 30 mins"`).
134 pub text: String,
135 /// Total duration in seconds (fractional).
136 pub total_seconds: f64,
137}
138
139/// A machine / hostname time-breakdown entry.
140///
141/// This is structurally similar to [`SummaryEntry`] but includes the
142/// `machine_name_id` field returned by the API.
143#[non_exhaustive]
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145pub struct MachineEntry {
146 /// Human-readable duration in `HH:MM` format.
147 pub digital: String,
148 /// Whole hours component.
149 pub hours: u32,
150 /// Disambiguated machine identifier returned by the API.
151 pub machine_name_id: String,
152 /// Whole minutes component (0–59).
153 pub minutes: u32,
154 /// Human-readable machine name.
155 pub name: String,
156 /// Percentage of total time for the period (0.0–100.0).
157 pub percent: f64,
158 /// Whole seconds component (0–59).
159 pub seconds: u32,
160 /// Full human-readable duration (e.g. `"1 hr 15 mins"`).
161 pub text: String,
162 /// Total duration in seconds (fractional).
163 pub total_seconds: f64,
164}
165
166/// Grand total coding time for a single calendar day.
167#[non_exhaustive]
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct GrandTotal {
170 /// Human-readable duration in `HH:MM` format.
171 pub digital: String,
172 /// Whole hours component.
173 pub hours: u32,
174 /// Whole minutes component (0–59).
175 pub minutes: u32,
176 /// Whole seconds component (0–59).
177 pub seconds: u32,
178 /// Full human-readable duration (e.g. `"6 hrs 42 mins"`).
179 pub text: String,
180 /// Total duration in seconds (fractional).
181 pub total_seconds: f64,
182}
183
184/// Date range metadata attached to a [`SummaryData`] entry.
185#[non_exhaustive]
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct SummaryRange {
188 /// Calendar date string (e.g. `"2025-01-13"`). Present on single-day
189 /// entries; may be absent on range queries.
190 pub date: Option<String>,
191 /// ISO 8601 end timestamp.
192 pub end: String,
193 /// ISO 8601 start timestamp.
194 pub start: String,
195 /// Human-readable description (e.g. `"today"`, `"yesterday"`).
196 pub text: Option<String>,
197 /// IANA timezone used when computing the range.
198 pub timezone: Option<String>,
199}
200
201// ─────────────────────────────────────────────────────────────────────────────
202// Projects list
203// ─────────────────────────────────────────────────────────────────────────────
204
205/// Top-level envelope returned by `GET /users/current/projects`.
206#[non_exhaustive]
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ProjectsResponse {
209 /// The list of projects.
210 pub data: Vec<Project>,
211}
212
213/// A `WakaTime` project.
214#[non_exhaustive]
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Project {
217 /// Optional badge URL associated with the project.
218 pub badge: Option<String>,
219 /// Optional hex color code used in the `WakaTime` web UI.
220 pub color: Option<String>,
221 /// ISO 8601 timestamp when the project was first seen.
222 pub created_at: String,
223 /// Whether the project has a public shareable URL.
224 pub has_public_url: bool,
225 /// Human-readable last-heartbeat description (e.g. `"2 hours ago"`).
226 pub human_readable_last_heartbeat_at: String,
227 /// Unique project identifier (UUID).
228 pub id: String,
229 /// ISO 8601 timestamp of the last received heartbeat.
230 pub last_heartbeat_at: String,
231 /// Project name.
232 pub name: String,
233 /// Linked repository URL (if configured).
234 pub repository: Option<String>,
235 /// Public project URL (if `has_public_url` is `true`).
236 pub url: Option<String>,
237 /// URL-encoded version of the project name.
238 pub urlencoded_name: String,
239}
240
241// ─────────────────────────────────────────────────────────────────────────────
242// Stats
243// ─────────────────────────────────────────────────────────────────────────────
244
245/// Top-level envelope returned by `GET /users/current/stats/{range}`.
246#[non_exhaustive]
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct StatsResponse {
249 /// The aggregated stats object.
250 pub data: Stats,
251}
252
253/// Aggregated coding stats for a predefined time range.
254#[non_exhaustive]
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Stats {
257 /// The day with the most coding activity.
258 pub best_day: Option<BestDay>,
259 /// Time broken down by activity category.
260 pub categories: Vec<SummaryEntry>,
261 /// ISO 8601 timestamp when this stats snapshot was created.
262 pub created_at: String,
263 /// Average daily coding time in seconds.
264 pub daily_average: f64,
265 /// Average daily coding time including "other language" seconds.
266 pub daily_average_including_other_language: f64,
267 /// Number of days in the range including weekends/holidays.
268 pub days_including_holidays: u32,
269 /// Number of working days in the range.
270 pub days_minus_holidays: u32,
271 /// Time broken down by editor.
272 pub editors: Vec<SummaryEntry>,
273 /// ISO 8601 end of the stats range.
274 pub end: String,
275 /// Number of holiday days detected in the range.
276 pub holidays: u32,
277 /// Human-readable average daily coding time.
278 pub human_readable_daily_average: String,
279 /// Human-readable description of the range (e.g. `"last 7 days"`).
280 pub human_readable_range: String,
281 /// Human-readable total coding time.
282 pub human_readable_total: String,
283 /// Unique stats record identifier.
284 pub id: String,
285 /// Whether the stats snapshot is up to date.
286 pub is_up_to_date: bool,
287 /// Time broken down by programming language.
288 pub languages: Vec<SummaryEntry>,
289 /// Time broken down by machine.
290 pub machines: Vec<MachineEntry>,
291 /// ISO 8601 timestamp when this snapshot was last modified.
292 pub modified_at: Option<String>,
293 /// Time broken down by operating system.
294 pub operating_systems: Vec<SummaryEntry>,
295 /// How fully computed the stats are (0–100).
296 pub percent_calculated: u32,
297 /// Time broken down by project.
298 pub projects: Vec<SummaryEntry>,
299 /// Named range (e.g. `"last_7_days"`, `"last_30_days"`).
300 pub range: String,
301 /// ISO 8601 start of the stats range.
302 pub start: String,
303 /// Computation status (e.g. `"ok"`, `"pending_update"`).
304 pub status: String,
305 /// Heartbeat timeout in minutes used for this calculation.
306 pub timeout: u32,
307 /// IANA timezone used when computing the stats.
308 pub timezone: String,
309 /// Total coding time in seconds.
310 pub total_seconds: f64,
311 /// Total coding time including "other language" in seconds.
312 pub total_seconds_including_other_language: f64,
313 /// Owner user identifier (UUID).
314 pub user_id: String,
315 /// Owner username.
316 pub username: String,
317 /// Whether the account is in write-only mode.
318 pub writes_only: bool,
319}
320
321/// The single best (most productive) day in a stats range.
322#[non_exhaustive]
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct BestDay {
325 /// Calendar date (e.g. `"2025-01-13"`).
326 pub date: String,
327 /// Human-readable total for that day.
328 pub text: String,
329 /// Total coding seconds for that day.
330 pub total_seconds: f64,
331}
332
333// ─────────────────────────────────────────────────────────────────────────────
334// Goals
335// ─────────────────────────────────────────────────────────────────────────────
336
337/// Top-level envelope returned by `GET /users/current/goals`.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct GoalsResponse {
340 /// The list of goals.
341 pub data: Vec<Goal>,
342 /// Total number of goals.
343 pub total: u32,
344 /// Total number of pages.
345 pub total_pages: u32,
346}
347
348/// A single coding goal.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct Goal {
351 /// Per-period chart data (populated when fetching goal details).
352 pub chart_data: Option<Vec<GoalChartEntry>>,
353 /// ISO 8601 timestamp when the goal was created.
354 pub created_at: String,
355 /// Goal period (e.g. `"day"`, `"week"`).
356 pub delta: String,
357 /// Editors this goal is restricted to (empty = all editors).
358 #[serde(default)]
359 pub editors: Vec<String>,
360 /// Unique goal identifier (UUID).
361 pub id: String,
362 /// Days of the week to ignore (e.g. `["saturday", "sunday"]`).
363 #[serde(default)]
364 pub ignore_days: Vec<String>,
365 /// Whether days with zero activity are excluded from streak calculations.
366 pub ignore_zero_days: bool,
367 /// Target improvement percentage over baseline.
368 pub improve_by_percent: Option<f64>,
369 /// Whether the goal is active.
370 pub is_enabled: bool,
371 /// Whether passing means staying *below* the target.
372 pub is_inverse: bool,
373 /// Whether the goal is temporarily snoozed.
374 pub is_snoozed: bool,
375 /// Whether achievements are tweeted automatically.
376 pub is_tweeting: bool,
377 /// Languages this goal is restricted to (empty = all languages).
378 #[serde(default)]
379 pub languages: Vec<String>,
380 /// ISO 8601 timestamp when the goal was last modified.
381 pub modified_at: String,
382 /// Projects this goal is restricted to (empty = all projects).
383 #[serde(default)]
384 pub projects: Vec<String>,
385 /// Status for the most recent period.
386 pub range_status: String,
387 /// Human-readable explanation of the status.
388 pub range_status_reason: String,
389 /// Target coding seconds per period.
390 pub seconds: f64,
391 /// Overall goal status (e.g. `"success"`, `"fail"`, `"ignored"`).
392 pub status: String,
393 /// Human-readable goal title.
394 pub title: String,
395 /// Goal type (e.g. `"coding"`).
396 #[serde(rename = "type")]
397 pub goal_type: String,
398}
399
400/// One data point in a goal's progress chart.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GoalChartEntry {
403 /// Actual coding seconds logged during this period.
404 pub actual_seconds: f64,
405 /// Human-readable actual coding time.
406 pub actual_seconds_text: String,
407 /// Target coding seconds for this period.
408 pub goal_seconds: f64,
409 /// Human-readable target coding time.
410 pub goal_seconds_text: String,
411 /// Date range this data point covers.
412 pub range: GoalChartRange,
413 /// Status for this period (e.g. `"success"`, `"fail"`).
414 pub range_status: String,
415 /// Human-readable explanation of the status.
416 pub range_status_reason: String,
417}
418
419/// Date range metadata for a [`GoalChartEntry`].
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct GoalChartRange {
422 /// Calendar date (e.g. `"2025-01-13"`).
423 pub date: String,
424 /// ISO 8601 end timestamp.
425 pub end: String,
426 /// ISO 8601 start timestamp.
427 pub start: String,
428 /// Human-readable description (e.g. `"Mon, Jan 13"`).
429 pub text: String,
430 /// IANA timezone used when computing the range.
431 pub timezone: String,
432}
433
434// ─────────────────────────────────────────────────────────────────────────────
435// Leaderboard
436// ─────────────────────────────────────────────────────────────────────────────
437
438/// Top-level envelope returned by `GET /users/current/leaderboards`.
439#[non_exhaustive]
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct LeaderboardResponse {
442 /// The current authenticated user's entry, if they appear in this page.
443 pub current_user: Option<LeaderboardEntry>,
444 /// The leaderboard entries for this page.
445 pub data: Vec<LeaderboardEntry>,
446 /// Language filter applied (if any).
447 pub language: Option<String>,
448 /// ISO 8601 timestamp when the leaderboard was last updated.
449 pub modified_at: String,
450 /// Current page number (1-based).
451 pub page: u32,
452 /// Date range this leaderboard covers.
453 pub range: LeaderboardRange,
454 /// Heartbeat timeout in minutes used for ranking.
455 pub timeout: u32,
456 /// Total number of pages available.
457 pub total_pages: u32,
458 /// Whether the leaderboard is restricted to write-only accounts.
459 pub writes_only: bool,
460}
461
462/// A single entry on the leaderboard.
463#[non_exhaustive]
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct LeaderboardEntry {
466 /// The rank of this user (1 = top coder).
467 pub rank: u32,
468 /// Aggregated coding totals for this user.
469 pub running_total: RunningTotal,
470 /// Public user information.
471 pub user: LeaderboardUser,
472}
473
474/// Aggregated coding totals used on the leaderboard.
475#[non_exhaustive]
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct RunningTotal {
478 /// Average daily coding time in seconds.
479 pub daily_average: f64,
480 /// Human-readable average daily coding time.
481 pub human_readable_daily_average: String,
482 /// Human-readable total coding time for the period.
483 pub human_readable_total: String,
484 /// Top languages for this user (may be empty if privacy settings prevent
485 /// disclosure).
486 #[serde(default)]
487 pub languages: Vec<SummaryEntry>,
488 /// ISO 8601 timestamp when this total was last computed.
489 pub modified_at: String,
490 /// Total coding seconds for the leaderboard period.
491 pub total_seconds: f64,
492}
493
494/// Public user information exposed on the leaderboard.
495#[non_exhaustive]
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct LeaderboardUser {
498 /// Human-readable display name.
499 pub display_name: String,
500 /// Public email (empty string if not shared).
501 pub email: Option<String>,
502 /// Full legal name.
503 pub full_name: Option<String>,
504 /// Human-readable website URL.
505 pub human_readable_website: Option<String>,
506 /// Unique user identifier (UUID).
507 pub id: String,
508 /// Whether the email address is publicly visible.
509 pub is_email_public: bool,
510 /// Whether the user is open to work.
511 pub is_hireable: bool,
512 /// Geographic location string.
513 pub location: Option<String>,
514 /// Avatar image URL.
515 pub photo: Option<String>,
516 /// Whether the avatar is publicly visible.
517 pub photo_public: bool,
518 /// Absolute URL of the user's public profile.
519 pub profile_url: Option<String>,
520 /// Public email address (distinct from account email).
521 pub public_email: Option<String>,
522 /// Login handle.
523 pub username: Option<String>,
524 /// Personal website URL.
525 pub website: Option<String>,
526}
527
528/// Date range metadata for a [`LeaderboardResponse`].
529#[non_exhaustive]
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct LeaderboardRange {
532 /// End date string (e.g. `"2025-01-13"`).
533 pub end_date: String,
534 /// Short human-readable end date (e.g. `"Jan 13"`).
535 pub end_text: String,
536 /// Named range identifier (e.g. `"last_7_days"`).
537 pub name: String,
538 /// Start date string (e.g. `"2025-01-07"`).
539 pub start_date: String,
540 /// Short human-readable start date (e.g. `"Jan 7"`).
541 pub start_text: String,
542 /// Full human-readable range description (e.g. `"Last 7 Days"`).
543 pub text: String,
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 /// Deserializing a minimal [`GrandTotal`] from JSON must succeed and
551 /// `total_seconds` must be preserved exactly.
552 #[test]
553 fn grand_total_deserialize() {
554 let json = r#"{
555 "digital": "6:42",
556 "hours": 6,
557 "minutes": 42,
558 "seconds": 0,
559 "text": "6 hrs 42 mins",
560 "total_seconds": 24120.0
561 }"#;
562 let gt: GrandTotal = serde_json::from_str(json).unwrap();
563 assert_eq!(gt.hours, 6);
564 assert_eq!(gt.minutes, 42);
565 #[allow(clippy::float_cmp)]
566 {
567 assert_eq!(gt.total_seconds, 24120.0);
568 }
569 assert_eq!(gt.text, "6 hrs 42 mins");
570 }
571
572 /// Unknown fields in the JSON must be silently ignored.
573 #[test]
574 fn summary_entry_ignores_unknown_fields() {
575 let json = r#"{
576 "digital": "3:30",
577 "hours": 3,
578 "minutes": 30,
579 "name": "Python",
580 "percent": 52.3,
581 "seconds": 0,
582 "text": "3 hrs 30 mins",
583 "total_seconds": 12600.0,
584 "some_future_field": "ignored"
585 }"#;
586 let entry: SummaryEntry = serde_json::from_str(json).unwrap();
587 assert_eq!(entry.name, "Python");
588 assert_eq!(entry.hours, 3);
589 }
590
591 /// `SummaryData::dependencies` must default to an empty vec when absent.
592 #[test]
593 fn summary_data_default_dependencies() {
594 let json = r#"{
595 "categories": [],
596 "editors": [],
597 "grand_total": {
598 "digital": "0:00",
599 "hours": 0,
600 "minutes": 0,
601 "seconds": 0,
602 "text": "0 secs",
603 "total_seconds": 0.0
604 },
605 "languages": [],
606 "operating_systems": [],
607 "projects": [],
608 "range": {
609 "end": "2025-01-14T00:00:00Z",
610 "start": "2025-01-13T00:00:00Z"
611 }
612 }"#;
613 let data: SummaryData = serde_json::from_str(json).unwrap();
614 assert!(data.dependencies.is_empty());
615 assert!(data.machines.is_empty());
616 }
617
618 /// A serialized `User` must round-trip through JSON without loss.
619 #[test]
620 fn user_roundtrip() {
621 let user = User {
622 id: "abc-123".to_owned(),
623 username: "johndoe".to_owned(),
624 display_name: "John Doe".to_owned(),
625 full_name: Some("John Doe".to_owned()),
626 email: None,
627 photo: None,
628 timezone: "UTC".to_owned(),
629 website: None,
630 human_readable_website: None,
631 location: None,
632 plan: Some("free".to_owned()),
633 profile_url: Some("https://wakatime.com/@johndoe".to_owned()),
634 is_email_confirmed: true,
635 is_hireable: Some(false),
636 logged_time_public: Some(false),
637 writes_only: false,
638 timeout: Some(15),
639 time_format_24hr: Some(false),
640 created_at: "2024-01-01T00:00:00Z".to_owned(),
641 modified_at: None,
642 };
643 let json = serde_json::to_string(&user).unwrap();
644 let user2: User = serde_json::from_str(&json).unwrap();
645 assert_eq!(user.id, user2.id);
646 assert_eq!(user.username, user2.username);
647 assert_eq!(user.timezone, user2.timezone);
648 }
649}