Compare commits

...

11 commits
v1.4.4 ... main

Author SHA1 Message Date
Paweł Orzech
3e214a0b19
Add feedback prompt and release v1.4.7
Add in-app feedback prompt with progressive timing thresholds
(1 hour, 1 week, 1 month) and 5-minute cooldown. Includes
FeedbackPromptView, AppFeedbackState model, and comprehensive
test coverage.
2026-01-27 23:43:17 +01:00
Paweł Orzech
b1804e5a69
fix: Prevent incorrect "Released" notification on travel arrival
The "Released! 🎉 - You are now free" notification was incorrectly
triggering when landing from airplane travel. Changed condition to
only fire when transitioning from Hospital or Jail status, not any
non-Okay state like Traveling.

Bumps version to 1.4.6.
2026-01-25 15:57:49 +01:00
Paweł Orzech
63cef35384
Add new version release instructions and v1.4.5 zip
Added a markdown file with instructions for creating a new version release, including changelog and version updates. Also added the MacTorn-v1.4.5.zip file for direct distribution.
2026-01-25 12:07:44 +01:00
Paweł Orzech
e4c8f6927b
fix: Improve travel timer accuracy with direct timestamp usage
- Use API timestamp directly for travel countdown calculations
- Add fallback to timeLeft for backward compatibility
- Add comprehensive test coverage for remainingSeconds method
- Bump version to 1.4.5
- Add CHANGELOG.md
2026-01-25 12:02:56 +01:00
Paweł Orzech
a55be3c6be
Update README.md 2026-01-20 13:31:42 +00:00
Paweł Orzech
a2d3e6416f
docs: Migrate wiki to GitHub Wiki feature
Move documentation from wiki/ folder to GitHub Wiki repository.
Add wiki link to README for easy access.
2026-01-20 13:30:20 +00:00
Paweł Orzech
715f0877ff
docs: Add comprehensive GitHub wiki documentation
Create wiki/ directory with 11 markdown pages covering:
- Home, Installation, Getting Started guides
- Features documentation for all tabs
- API Setup with permissions and security
- Configuration options and settings
- Troubleshooting and FAQ
- Development guide with architecture overview
- Changelog with version history
- Sidebar navigation
2026-01-20 13:24:55 +00:00
Paweł Orzech
e10add9474
fix: Resolve Swift concurrency errors by extracting MainActor functions
Extract watchlist mutations into dedicated @MainActor functions to avoid
actor-isolated property access issues in async context. This fixes CI
build failures caused by strict concurrency checking.
2026-01-20 13:22:26 +00:00
Paweł Orzech
9724bcbacb
Fix watchlist item mutation to update via copy
Refactored watchlist item updates to use value semantics by copying, modifying, and reassigning the item in the array. This prevents issues with direct mutation of value types in Swift arrays and ensures changes are properly reflected.
2026-01-20 13:18:42 +00:00
Paweł Orzech
8a4fb30cad
chore: Remove root .DS_Store from git tracking 2026-01-20 13:12:19 +00:00
Paweł Orzech
4414a2696a
chore: Remove .DS_Store from git tracking 2026-01-20 13:10:25 +00:00
16 changed files with 600 additions and 31 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,15 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.

3
.gitignore vendored
View file

@ -68,3 +68,6 @@ DerivedData/
# Archive folder (old releases)
archive/
# Claude Code
CLAUDE.md

73
CHANGELOG.md Normal file
View file

@ -0,0 +1,73 @@
# Changelog
All notable changes to MacTorn will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.7] - 2026-01-27
### Added
- In-app feedback prompt with smart timing (1 hour, 1 week, 1 month thresholds)
- Positive feedback links to Torn forums thread
- Negative feedback opens email for direct developer contact
- 5-minute cooldown between prompt dismissals
- Comprehensive test coverage for feedback logic
## [1.4.6] - 2025-01-25
### Fixed
- Fixed incorrect "Released" notification triggering when landing from travel
- "Released! 🎉 - You are now free" notification now only fires when released from Hospital or Jail, not when arriving from airplane travel
## [1.4.5] - 2025-01-25
### Fixed
- Improved travel timer accuracy by using API timestamp directly instead of calculating from fetch time offset
- Travel countdown now stays synchronized regardless of network delays or fetch timing
### Added
- Comprehensive test coverage for travel timer calculations
## [1.4.4] - Previous Release
### Fixed
- Resolve Swift concurrency errors by extracting MainActor functions
- Fix watchlist item mutation to update via copy
### Added
- Universal Binary support for Intel and Apple Silicon Macs
- Improved accessibility support
- Display cooldown labels as text instead of icons
## [1.4.3] - Earlier Release
### Added
- GitHub wiki documentation
- Migrated wiki to GitHub Wiki feature
## [1.4.2] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4.1] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4] - Initial Public Release
### Added
- Native macOS menu bar app for Torn game monitoring
- Status tab with live bars, cooldowns, and travel monitoring
- Travel tab with live countdown timer in menu bar
- Money tab with cash, vault, points display
- Attacks tab with battle stats and recent attacks
- Faction tab with chain status
- Watchlist tab for item price tracking
- Smart notifications for various game events
- Configurable refresh intervals
- Launch at login support
- Light and dark mode support
- Accessibility support with Reduce Transparency

BIN
MacTorn-v1.4.5.zip Normal file

Binary file not shown.

BIN
MacTorn-v1.4.6.zip Normal file

Binary file not shown.

BIN
MacTorn-v1.4.7.zip Normal file

Binary file not shown.

BIN
MacTorn/.DS_Store vendored

Binary file not shown.

View file

@ -31,6 +31,7 @@
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.swift */; };
AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; };
AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.swift */; };
/* Unit Tests */
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
@ -44,6 +45,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; };
BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.swift */; };
BBB00013 /* AppStateFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10013 /* AppStateFeedbackTests.swift */; };
/* UI Tests */
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
/* End PBXBuildFile section */
@ -91,6 +93,7 @@
AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = "<group>"; };
AAA10024 /* CreditsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsView.swift; sourceTree = "<group>"; };
AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; };
AAA10026 /* FeedbackPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPromptView.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* Unit Test Files */
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
@ -105,6 +108,7 @@
BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; };
BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = "<group>"; };
BBB10013 /* AppStateFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFeedbackTests.swift; sourceTree = "<group>"; };
BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* UI Test Files */
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
@ -213,6 +217,7 @@
AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */,
AAA10026 /* FeedbackPromptView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -292,6 +297,7 @@
children = (
BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */,
BBB10013 /* AppStateFeedbackTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -457,6 +463,7 @@
AAA00022 /* TravelView.swift in Sources */,
AAA00023 /* CreditsView.swift in Sources */,
AAA00024 /* TransparencyEnvironment.swift in Sources */,
AAA00025 /* FeedbackPromptView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -476,6 +483,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */,
BBB00011 /* AppStateTests.swift in Sources */,
BBB00012 /* AppStateWatchlistTests.swift in Sources */,
BBB00013 /* AppStateFeedbackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -639,7 +647,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -666,7 +674,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -684,7 +692,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -702,7 +710,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -720,7 +728,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -737,7 +745,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.4;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;

View file

@ -115,6 +115,13 @@ struct Travel: Codable, Equatable {
/// Calculate remaining seconds based on fetch time (for live countdown)
func remainingSeconds(from fetchTime: Date) -> Int {
// Primary: Use timestamp directly if available (more accurate)
if let timestamp = timestamp, timestamp > 0 {
let now = Int(Date().timeIntervalSince1970)
return max(0, timestamp - now)
}
// Fallback: Use timeLeft with fetchTime offset (backward compatibility)
guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 }
let elapsed = Int(Date().timeIntervalSince(fetchTime))
return max(0, timeLeft - elapsed)
@ -727,6 +734,14 @@ enum NotificationSound: String, CaseIterable {
}
}
// MARK: - App Feedback State
struct AppFeedbackState: Codable {
var firstLaunchDate: Date
var hasResponded: Bool
var dismissCount: Int
var lastDismissedDate: Date?
}
// MARK: - Keyboard Shortcuts
struct KeyboardShortcut: Identifiable, Codable, Equatable {
let id: String

View file

@ -46,6 +46,11 @@ class AppState: ObservableObject {
// MARK: - Update State
@Published var updateAvailable: GitHubRelease?
// MARK: - Feedback State
@Published var feedbackState: AppFeedbackState?
@Published var showFeedbackPrompt: Bool = false
static let feedbackThresholds: [TimeInterval] = [3600, 7 * 86400, 30 * 86400]
// MARK: - Fetch Time (for live countdown calculations)
@Published var lastFetchTime: Date = Date()
@ -76,6 +81,7 @@ class AppState: ObservableObject {
loadNotificationRules()
loadTravelNotificationSettings()
loadWatchlist()
loadFeedbackState()
// Polling and permissions moved to onAppear in UI
}
@ -299,26 +305,12 @@ class AppState: ObservableObject {
let sortedListings = allListings.sorted { $0.price < $1.price }
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
await MainActor.run {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
if let best = sortedListings.first {
watchlistItems[index].lowestPrice = best.price
watchlistItems[index].lowestPriceQuantity = best.amount
// Check for next distinct price or just next listing? usually user wants to know diff to next cheapest offer even if it's same price?
// Actually "second lowest price" usually implies the price of the *next available item*.
// But usually users want to know price steps.
// Let's stick to simple logic: price of the 2nd listing in sorted list.
watchlistItems[index].secondLowestPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
watchlistItems[index].lastUpdated = Date()
watchlistItems[index].error = nil
} else {
watchlistItems[index].error = "No listings"
}
saveWatchlist()
}
if let best = sortedListings.first {
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice)
} else {
await updateItemError(itemId: itemId, error: "No listings")
}
}
} catch {
@ -327,10 +319,26 @@ class AppState: ObservableObject {
}
}
@MainActor
private func updateItemPrice(itemId: Int, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index]
item.lowestPrice = lowestPrice
item.lowestPriceQuantity = lowestPriceQuantity
item.secondLowestPrice = secondLowestPrice
item.lastUpdated = Date()
item.error = nil
watchlistItems[index] = item
saveWatchlist()
}
}
@MainActor
private func updateItemError(itemId: Int, error: String) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
watchlistItems[index].error = error
var item = watchlistItems[index]
item.error = error
watchlistItems[index] = item
saveWatchlist()
}
}
@ -570,6 +578,9 @@ class AppState: ObservableObject {
// Manage travel timer after data is set
self.manageTravelTimer()
// Check if feedback prompt should be shown
self.checkFeedbackPrompt()
// Force UI update by triggering objectWillChange
self.objectWillChange.send()
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
@ -660,7 +671,7 @@ class AppState: ObservableObject {
}
if let prevStatus = previousStatus, let currentStatus = newData.status {
if !prevStatus.isOkay && currentStatus.isOkay {
if (prevStatus.isInHospital || prevStatus.isInJail) && currentStatus.isOkay {
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
}
}
@ -702,7 +713,7 @@ class AppState: ObservableObject {
// MARK: - Updates
func checkForAppUpdates() {
guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
Task {
if let release = await updateManager.checkForUpdates(currentVersion: currentVersion) {
await MainActor.run {
@ -711,6 +722,73 @@ class AppState: ObservableObject {
}
}
}
// MARK: - Feedback Prompt
func loadFeedbackState() {
if let data = UserDefaults.standard.data(forKey: "appFeedbackState"),
let state = try? JSONDecoder().decode(AppFeedbackState.self, from: data) {
feedbackState = state
} else {
feedbackState = AppFeedbackState(
firstLaunchDate: Date(),
hasResponded: false,
dismissCount: 0,
lastDismissedDate: nil
)
saveFeedbackState()
}
}
func saveFeedbackState() {
guard let state = feedbackState,
let data = try? JSONEncoder().encode(state) else { return }
UserDefaults.standard.set(data, forKey: "appFeedbackState")
}
func checkFeedbackPrompt() {
guard let state = feedbackState else { return }
guard !state.hasResponded else { return }
guard state.dismissCount < Self.feedbackThresholds.count else { return }
// 5-minute cooldown after last dismissal
if let lastDismissed = state.lastDismissedDate,
Date().timeIntervalSince(lastDismissed) < 300 {
return
}
let elapsed = Date().timeIntervalSince(state.firstLaunchDate)
let requiredTime = Self.feedbackThresholds[state.dismissCount]
if elapsed >= requiredTime {
showFeedbackPrompt = true
}
}
func feedbackRespondedPositive() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "https://www.torn.com/forums.php#/p=threads&f=67&t=16532308") {
NSWorkspace.shared.open(url)
}
}
func feedbackRespondedNegative() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "mailto:pawel@orzech.lol?subject=MacTorn%20Feedback") {
NSWorkspace.shared.open(url)
}
}
func feedbackDismissed() {
feedbackState?.dismissCount += 1
feedbackState?.lastDismissedDate = Date()
showFeedbackPrompt = false
saveFeedbackState()
}
}
// MARK: - Errors

View file

@ -0,0 +1,74 @@
import SwiftUI
struct FeedbackPromptView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
VStack(spacing: 16) {
Image(systemName: "heart.fill")
.font(.system(size: 32))
.foregroundStyle(
LinearGradient(
colors: [.pink, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Enjoying MacTorn?")
.font(.headline)
Text("Your feedback helps make the app better for everyone.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Button {
appState.feedbackRespondedPositive()
} label: {
HStack(spacing: 6) {
Image(systemName: "hand.thumbsup.fill")
Text("Yes! Leave a review")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
Button {
appState.feedbackRespondedNegative()
} label: {
HStack(spacing: 6) {
Image(systemName: "envelope.fill")
Text("Not really — send feedback")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
Button {
appState.feedbackDismissed()
} label: {
Text("Not now")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(20)
.frame(width: 260)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(reduceTransparency ? Color(.windowBackgroundColor) : Color(.windowBackgroundColor).opacity(0.95))
.shadow(radius: 8)
)
}
}

View file

@ -58,7 +58,7 @@ struct ContentView: View {
if appState.isLoading && appState.lastUpdated == nil {
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
@ -67,6 +67,15 @@ struct ContentView: View {
.foregroundColor(.secondary)
}
}
// Feedback Prompt Overlay
if appState.showFeedbackPrompt {
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.3))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
FeedbackPromptView()
.environmentObject(appState)
}
}
.frame(width: 320)
.onAppear {

View file

@ -132,4 +132,108 @@ final class TravelTests: XCTestCase {
let travel = try decode(Travel.self, from: json)
XCTAssertFalse(travel.isTraveling)
}
// MARK: - remainingSeconds Tests
func testRemainingSeconds_usesTimestampDirectly() throws {
// Set arrival time 60 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 60
]
let travel = try decode(Travel.self, from: json)
// Even with a stale fetchTime, should use timestamp directly
let staleFetchTime = Date().addingTimeInterval(-300) // 5 minutes ago
let remaining = travel.remainingSeconds(from: staleFetchTime)
// Should be approximately 60 seconds (allow 1-2 seconds tolerance for test execution)
XCTAssertGreaterThanOrEqual(remaining, 58)
XCTAssertLessThanOrEqual(remaining, 62)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampNil() throws {
let json: [String: Any] = [
"destination": "Japan",
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is nil
XCTAssertGreaterThanOrEqual(remaining, 118)
XCTAssertLessThanOrEqual(remaining, 120)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampZero() throws {
let json: [String: Any] = [
"destination": "Japan",
"timestamp": 0,
"time_left": 90
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is 0
XCTAssertGreaterThanOrEqual(remaining, 88)
XCTAssertLessThanOrEqual(remaining, 90)
}
func testRemainingSeconds_returnsZeroWhenArrivalPassed() throws {
// Set arrival time in the past
let pastTimestamp = Int(Date().timeIntervalSince1970) - 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": pastTimestamp,
"departed": pastTimestamp - 1000,
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
func testRemainingSeconds_consistentRegardlessOfFetchTime() throws {
// Set arrival time 120 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 120
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
// Test with different fetchTimes - result should be the same
let recentFetchTime = Date()
let staleFetchTime = Date().addingTimeInterval(-60)
let veryOldFetchTime = Date().addingTimeInterval(-600)
let remaining1 = travel.remainingSeconds(from: recentFetchTime)
let remaining2 = travel.remainingSeconds(from: staleFetchTime)
let remaining3 = travel.remainingSeconds(from: veryOldFetchTime)
// All should return approximately the same value (within 1 second tolerance)
XCTAssertEqual(remaining1, remaining2, accuracy: 1)
XCTAssertEqual(remaining2, remaining3, accuracy: 1)
}
func testRemainingSeconds_zeroWhenNotTraveling() throws {
let json: [String: Any] = [
"destination": "Torn",
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
}

View file

@ -0,0 +1,186 @@
import XCTest
@testable import MacTorn
@MainActor
final class AppStateFeedbackTests: XCTestCase {
var mockSession: MockNetworkSession!
var appState: AppState!
override func setUp() async throws {
try await super.setUp()
mockSession = MockNetworkSession()
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
appState = AppState(session: mockSession)
}
override func tearDown() async throws {
appState.stopPolling()
appState = nil
mockSession = nil
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
try await super.tearDown()
}
// MARK: - First Launch
func testFirstLaunch_createsFeedbackState() {
XCTAssertNotNil(appState.feedbackState)
XCTAssertFalse(appState.feedbackState!.hasResponded)
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
XCTAssertNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - Threshold Logic
func testBeforeOneHour_promptDoesNotShow() {
// firstLaunchDate is just now, so less than 1 hour has elapsed
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterOneHour_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_needsOneWeek() {
// Set first launch to 2 hours ago, dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-2 * 3600)
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600) // 10 min ago (past cooldown)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 2 hours < 1 week, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_afterOneWeek_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-8 * 86400) // 8 days ago
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_needsOneMonth() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-14 * 86400) // 14 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 14 days < 30 days, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_afterOneMonth_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-31 * 86400) // 31 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissThreeTimes_neverShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400) // 1 year ago
appState.feedbackState?.dismissCount = 3
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Responses
func testPositiveResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedPositive()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testNegativeResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedNegative()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Dismiss
func testDismiss_incrementsDismissCount() {
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
appState.feedbackDismissed()
XCTAssertEqual(appState.feedbackState!.dismissCount, 1)
XCTAssertFalse(appState.showFeedbackPrompt)
XCTAssertNotNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - After Responded
func testAfterResponded_neverShowsAgain() {
appState.feedbackState?.hasResponded = true
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Persistence
func testStatePersistsAcrossAppStateInstances() {
// Set a specific first launch date and dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-86400)
appState.feedbackState?.dismissCount = 1
appState.saveFeedbackState()
// Create a new AppState instance (simulates app restart)
let newAppState = AppState(session: mockSession)
XCTAssertNotNil(newAppState.feedbackState)
XCTAssertEqual(newAppState.feedbackState!.dismissCount, 1)
// firstLaunchDate should be approximately 1 day ago
let elapsed = Date().timeIntervalSince(newAppState.feedbackState!.firstLaunchDate)
XCTAssertTrue(elapsed > 86300 && elapsed < 86500)
newAppState.stopPolling()
}
// MARK: - Cooldown
func testFiveMinuteCooldown_preventsImmediateReshow() {
// Set eligible threshold (1 hour elapsed, dismissCount 0)
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// But dismissed just 2 minutes ago
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-120)
// dismissCount is still 0 since we're simulating the state manually
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterCooldown_promptCanShow() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// Dismissed 6 minutes ago (past the 5-minute cooldown)
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-360)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
}

View file

@ -13,6 +13,10 @@ A native macOS menu bar app for monitoring your **Torn** game status.
<img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320">
</p>
## Documentation
For detailed documentation, visit the [MacTorn Wiki](https://github.com/pawelorzech/MacTorn/wiki).
## Features
### 📊 Status Tab
@ -23,7 +27,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
- Hospital/Jail status badges
- Unread messages badge
- Events feed
- 8 customizable quick links
- 8 quick links
### ✈️ Travel Tab
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)