diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 66227b61..2e00a93d 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -128,6 +128,7 @@ const MIGRATIONS = [ name: "003_add_starredProjectIds_to_settings", sql: ` ALTER TABLE settings ADD COLUMN starredProjectIds TEXT; + ALTER TABLE settings ADD COLUMN lastAckedStarredProjectChangesJwtId TEXT; `, }, ]; diff --git a/src/interfaces/claims.ts b/src/interfaces/claims.ts index c028858b..1fc03529 100644 --- a/src/interfaces/claims.ts +++ b/src/interfaces/claims.ts @@ -72,11 +72,15 @@ export interface PlanActionClaim extends ClaimObject { name: string; agent?: { identifier: string }; description?: string; + endTime?: string; identifier?: string; + image?: string; lastClaimId?: string; location?: { geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; }; + startTime?: string; + url?: string; } // AKA Registration & RegisterAction diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts index e5e72c55..6c2cf3cf 100644 --- a/src/interfaces/records.ts +++ b/src/interfaces/records.ts @@ -1,5 +1,5 @@ -import { GiveActionClaim, OfferClaim } from "./claims"; -import { ClaimObject } from "./common"; +import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims"; +import { GenericCredWrapper } from "./common"; // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { @@ -64,7 +64,7 @@ export interface PlanSummaryRecord { export interface PlanSummaryAndPreviousClaim { plan: PlanSummaryRecord; - wrappedClaimBefore: ClaimObject; + wrappedClaimBefore: GenericCredWrapper; } /** diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index 43b90ab0..934c70d0 100644 --- a/src/views/NewActivityView.vue +++ b/src/views/NewActivityView.vue @@ -48,7 +48,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }} offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -70,7 +70,7 @@ @click="markOffersAsReadStartingWith(offer.jwtId)" > - Click to keep all above as new offers + Click to keep all above as unread offers @@ -115,7 +115,7 @@ didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) }} offered - {{ + {{ offer.objectDescription }}{{ offer.objectDescription && offer.amount ? ", and " : "" }} @@ -139,7 +139,7 @@ @click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)" > - Click to keep all above as new offers + Click to keep all above as unread offers @@ -182,31 +182,90 @@ {{ projectChange.plan.name || "Unnamed Project" }} - + - {{ projectChange.plan.description }} + +
+
Changes
+
+ + + + + + + + + + + + + + + +
+ Previous + + Current +
+ {{ getDisplayFieldName(field) }} + + {{ formatFieldValue(difference.old) }} + + {{ formatFieldValue(difference.new) }} +
+
+
@@ -227,6 +286,7 @@ import { OfferSummaryRecord, OfferToPlanSummaryRecord, PlanSummaryAndPreviousClaim, + PlanSummaryRecord, } from "../interfaces/records"; import { didInfo, @@ -240,6 +300,9 @@ import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import * as databaseUtil from "../db/databaseUtil"; +import * as R from "ramda"; +import { PlanActionClaim } from "../interfaces/claims"; +import { GenericCredWrapper } from "@/interfaces"; @Component({ components: { GiftedDialog, QuickNav, EntityIcon }, @@ -264,6 +327,10 @@ export default class NewActivityView extends Vue { newStarredProjectChanges: Array = []; newStarredProjectChangesHitLimit = false; starredProjectIds: Array = []; + planDifferences: Record< + string, + Record + > = {}; showOffersDetails = false; showOffersToUserProjectsDetails = false; @@ -323,6 +390,9 @@ export default class NewActivityView extends Vue { this.newStarredProjectChanges = starredProjectChangesData.data; this.newStarredProjectChangesHitLimit = starredProjectChangesData.hitLimit; + + // Analyze differences between current plans and previous claims + this.analyzePlanDifferences(this.newStarredProjectChanges); } catch (error) { logger.warn("Failed to load starred project changes:", error); this.newStarredProjectChanges = []; @@ -349,7 +419,7 @@ export default class NewActivityView extends Vue { // note that we don't update this.lastAckedOfferToUserJwtId in case they // later choose the last one to keep the offers as new this.notify.info( - "The offers are marked as viewed. Click in the list to keep them as new.", + "The offers are marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -387,7 +457,7 @@ export default class NewActivityView extends Vue { // note that we don't update this.lastAckedOfferToUserProjectsJwtId in case // they later choose the last one to keep the offers as new this.notify.info( - "The offers are now marked as viewed. Click in the list to keep them as new.", + "The offers are now marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -428,7 +498,7 @@ export default class NewActivityView extends Vue { this.newStarredProjectChanges[0].plan.jwtId, }); this.notify.info( - "The starred project changes are now marked as viewed. Click in the list to keep them as new.", + "The starred project changes are now marked read. Click in the list to keep them unread.", TIMEOUTS.LONG, ); } @@ -456,5 +526,310 @@ export default class NewActivityView extends Vue { TIMEOUTS.STANDARD, ); } + + /** + * Normalizes values for comparison - treats null, undefined, and empty string as equivalent + * + * @param value The value to normalize + * @returns The normalized value (null for null/undefined/empty, otherwise the original value) + */ + normalizeValueForComparison(value: unknown): unknown { + if (value === null || value === undefined || value === "") { + return null; + } + return value; + } + + /** + * Analyzes differences between current plans and their previous claims + * + * Walks through a list of PlanSummaryAndPreviousClaim items and stores the + * differences between the previous claim and the current plan. This method + * extracts the claim from the wrappedClaimBefore object and compares relevant + * fields with the current plan. + * + * @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze + */ + analyzePlanDifferences(planChanges: Array) { + this.planDifferences = {}; + + for (const planChange of planChanges) { + console.log("planChange", planChange); + const currentPlan: PlanSummaryRecord = planChange.plan; + const wrappedClaim: GenericCredWrapper = + planChange.wrappedClaimBefore; + + // Extract the actual claim from the wrapped claim + let previousClaim: PlanActionClaim; + + const embeddedClaim: string = wrappedClaim.claim; + if ( + embeddedClaim && + typeof embeddedClaim === "object" && + "credentialSubject" in embeddedClaim + ) { + // It's a Verifiable Credential + previousClaim = + (embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim; + } else { + // It's a direct claim + previousClaim = embeddedClaim; + } + + if (!previousClaim || !currentPlan.handleId) { + continue; + } + + const differences: Record = {}; + + // Compare name + const normalizedOldName = this.normalizeValueForComparison( + previousClaim.name, + ); + const normalizedNewName = this.normalizeValueForComparison( + currentPlan.name, + ); + if (!R.equals(normalizedOldName, normalizedNewName)) { + differences.name = { + old: previousClaim.name, + new: currentPlan.name, + }; + } + + // Compare description + const normalizedOldDescription = this.normalizeValueForComparison( + previousClaim.description, + ); + const normalizedNewDescription = this.normalizeValueForComparison( + currentPlan.description, + ); + if (!R.equals(normalizedOldDescription, normalizedNewDescription)) { + differences.description = { + old: previousClaim.description, + new: currentPlan.description, + }; + } + + // Compare location (combine latitude and longitude into one row) + const oldLat = previousClaim.location?.geo?.latitude; + const oldLon = previousClaim.location?.geo?.longitude; + const newLat = currentPlan.locLat; + const newLon = currentPlan.locLon; + + if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) { + differences.location = { + old: this.formatLocationValue(oldLat, oldLon, true), + new: this.formatLocationValue(newLat, newLon, false), + }; + } + + // Compare agent (issuer) + const oldAgent = previousClaim.agent?.identifier; + const newAgent = currentPlan.agentDid; + const normalizedOldAgent = this.normalizeValueForComparison(oldAgent); + const normalizedNewAgent = this.normalizeValueForComparison(newAgent); + if (!R.equals(normalizedOldAgent, normalizedNewAgent)) { + differences.agent = { + old: oldAgent, + new: newAgent, + }; + } + + // Compare start time + const oldStartTime = previousClaim.startTime; + const newStartTime = currentPlan.startTime; + const normalizedOldStartTime = + this.normalizeValueForComparison(oldStartTime); + const normalizedNewStartTime = + this.normalizeValueForComparison(newStartTime); + if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) { + differences.startTime = { + old: oldStartTime, + new: newStartTime, + }; + } + + // Compare end time + const oldEndTime = previousClaim.endTime; + const newEndTime = currentPlan.endTime; + const normalizedOldEndTime = this.normalizeValueForComparison(oldEndTime); + const normalizedNewEndTime = this.normalizeValueForComparison(newEndTime); + if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) { + differences.endTime = { + old: oldEndTime, + new: newEndTime, + }; + } + + // Compare image + const oldImage = previousClaim.image; + const newImage = currentPlan.image; + const normalizedOldImage = this.normalizeValueForComparison(oldImage); + const normalizedNewImage = this.normalizeValueForComparison(newImage); + if (!R.equals(normalizedOldImage, normalizedNewImage)) { + differences.image = { + old: oldImage, + new: newImage, + }; + } + + // Compare url + const oldUrl = previousClaim.url; + const newUrl = currentPlan.url; + const normalizedOldUrl = this.normalizeValueForComparison(oldUrl); + const normalizedNewUrl = this.normalizeValueForComparison(newUrl); + if (!R.equals(normalizedOldUrl, normalizedNewUrl)) { + differences.url = { + old: oldUrl, + new: newUrl, + }; + } + + // Store differences if any were found + if (!R.isEmpty(differences)) { + this.planDifferences[currentPlan.handleId] = differences; + logger.debug( + "[NewActivityView] Plan differences found for", + currentPlan.handleId, + differences, + ); + } + } + + logger.debug( + "[NewActivityView] Analyzed", + planChanges.length, + "plan changes, found differences in", + Object.keys(this.planDifferences).length, + "plans", + ); + } + + /** + * Gets the differences for a specific plan by handle ID + * + * @param handleId The handle ID of the plan to get differences for + * @returns The differences object or null if no differences found + */ + getPlanDifferences( + handleId: string, + ): Record | null { + return this.planDifferences[handleId] || null; + } + + /** + * Formats a field value for display in the UI + * + * @param value The value to format + * @returns A human-readable string representation + */ + formatFieldValue(value: unknown): string { + if (value === null || value === undefined) { + return "Not set"; + } + if (typeof value === "string") { + const stringValue = value || "Empty"; + + // Check if it's a date/time string + if (this.isDateTimeString(stringValue)) { + return this.formatDateTime(stringValue); + } + + // Check if it's a URL + if (this.isUrl(stringValue)) { + return stringValue; // Keep URLs as-is for now + } + + return stringValue; + } + if (typeof value === "number") { + return value.toString(); + } + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + // For complex objects, stringify + const stringified = JSON.stringify(value); + return stringified; + } + + /** + * Checks if a string appears to be a date/time string + */ + isDateTimeString(value: string): boolean { + if (!value) return false; + // Check for ISO 8601 format or other common date formats + const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/; + return dateRegex.test(value) || !isNaN(Date.parse(value)); + } + + /** + * Checks if a string is a URL + */ + isUrl(value: string): boolean { + if (!value) return false; + try { + new URL(value); + return true; + } catch { + return false; + } + } + + /** + * Formats a date/time string for display + */ + formatDateTime(value: string): string { + try { + const date = new Date(value); + return date.toLocaleString(); + } catch { + return value; // Return original if parsing fails + } + } + + /** + * Gets a human-readable field name for display + * + * @param fieldName The internal field name + * @returns A formatted field name for display + */ + getDisplayFieldName(fieldName: string): string { + const fieldNameMap: Record = { + name: "Name", + description: "Description", + location: "Location", + agent: "Agent", + startTime: "Start Time", + endTime: "End Time", + image: "Image", + url: "URL", + }; + return fieldNameMap[fieldName] || fieldName; + } + + /** + * Formats location values for display + * + * @param latitude The latitude value + * @param longitude The longitude value + * @param isOldValue Whether this is the old value (true) or new value (false) + * @returns A formatted location string + */ + formatLocationValue( + latitude: number | undefined, + longitude: number | undefined, + isOldValue: boolean = false, + ): string { + if (latitude === undefined && longitude === undefined) { + return "Not set"; + } + // If there's any location data, show generic labels instead of coordinates + if (isOldValue) { + return "A Location"; + } else { + return "New Location"; + } + } } diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index c82be500..59053b2d 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -169,9 +169,10 @@ export async function generateAndRegisterEthrUser(page: Page): Promise { } // Function to generate a random string of specified length +// Note that this only generates up to 10 characters export async function generateRandomString(length: number): Promise { return Math.random() - .toString(36) + .toString(36) // base 36 only generates up to 10 characters .substring(2, 2 + length); } @@ -180,7 +181,7 @@ export async function createUniqueStringsArray( count: number ): Promise { const stringsArray: string[] = []; - const stringLength = 16; + const stringLength = 5; // max of 10; see generateRandomString for (let i = 0; i < count; i++) { let randomString = await generateRandomString(stringLength);