Skip to content

Commit bfe73cb

Browse files
committed
feat(devices): tailor apps to buyer workflows
1 parent 175720f commit bfe73cb

7 files changed

Lines changed: 902 additions & 11 deletions

File tree

data/apple-silicon-devices.json

Lines changed: 347 additions & 1 deletion
Large diffs are not rendered by default.

helpers/device-app-curation.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
const defaultListSize = 12
2+
3+
function isAppListing ( value ) {
4+
return value !== null
5+
&& typeof value === 'object'
6+
&& typeof value.slug === 'string'
7+
&& typeof value.endpoint === 'string'
8+
&& value.endpoint.startsWith( '/app/' )
9+
}
10+
11+
function withoutHeavyListingFields ( listing ) {
12+
return {
13+
...listing,
14+
bundles: undefined,
15+
relatedVideos: undefined
16+
}
17+
}
18+
19+
function getListingTimestamp ( listing ) {
20+
const timestamp = Number( listing?.lastUpdated?.timestamp )
21+
22+
return Number.isFinite( timestamp ) ? timestamp : 0
23+
}
24+
25+
function sortNewestFirst ( left, right ) {
26+
const timestampDifference = getListingTimestamp( right ) - getListingTimestamp( left )
27+
28+
if ( timestampDifference !== 0 ) return timestampDifference
29+
30+
return left.name.localeCompare( right.name )
31+
}
32+
33+
function addUniqueListing ( target, seenSlugs, listing ) {
34+
if ( !isAppListing( listing ) || seenSlugs.has( listing.slug ) ) return false
35+
36+
target.push( withoutHeavyListingFields( listing ) )
37+
seenSlugs.add( listing.slug )
38+
39+
return true
40+
}
41+
42+
function getRotatingCategoryMatches ({
43+
device,
44+
categoryPages,
45+
excludedSlugs
46+
}) {
47+
return device.buyerProfile.appCategories.flatMap( category => {
48+
const page = categoryPages[ category.slug ]
49+
const items = Array.isArray( page?.items ) ? page.items : []
50+
const candidates = items
51+
.filter( listing => {
52+
return isAppListing( listing )
53+
&& listing.category?.slug === category.slug
54+
&& !excludedSlugs.has( listing.slug )
55+
})
56+
.sort( sortNewestFirst )
57+
const supportedCandidates = candidates.filter( listing => {
58+
return ![
59+
'unreported',
60+
'no'
61+
].includes( listing.status )
62+
} )
63+
const rotationCandidates = supportedCandidates.length > 0
64+
? supportedCandidates
65+
: candidates
66+
const rotationOffset = Number.isInteger( category.rotationOffset )
67+
? category.rotationOffset
68+
: 0
69+
const rotatedMatch = rotationCandidates.length > 0
70+
? rotationCandidates[ rotationOffset % rotationCandidates.length ]
71+
: null
72+
73+
return rotatedMatch ? [ rotatedMatch ] : []
74+
} )
75+
}
76+
77+
export function makeCuratedDeviceAppPage ({
78+
device,
79+
featuredListings = [],
80+
categoryPages = {},
81+
listSize = defaultListSize
82+
}) {
83+
const featuredBySlug = new Map(
84+
featuredListings
85+
.filter( isAppListing )
86+
.map( listing => [ listing.slug, listing ] )
87+
)
88+
const stableListings = device.buyerProfile.featuredAppSlugs
89+
.flatMap( slug => {
90+
const listing = featuredBySlug.get( slug )
91+
92+
return listing ? [ listing ] : []
93+
} )
94+
const stableSlugs = new Set( stableListings.map( listing => listing.slug ) )
95+
const rotatingListings = getRotatingCategoryMatches({
96+
device,
97+
categoryPages,
98+
excludedSlugs: stableSlugs
99+
})
100+
const items = []
101+
const seenSlugs = new Set()
102+
let rotatingIndex = 0
103+
104+
for ( const [ stableIndex, listing ] of stableListings.entries() ) {
105+
addUniqueListing( items, seenSlugs, listing )
106+
107+
const shouldAddRotatingListing = ( stableIndex + 1 ) % 2 === 0
108+
109+
if ( shouldAddRotatingListing && rotatingIndex < rotatingListings.length ) {
110+
addUniqueListing(
111+
items,
112+
seenSlugs,
113+
rotatingListings[ rotatingIndex ]
114+
)
115+
rotatingIndex += 1
116+
}
117+
}
118+
119+
while ( rotatingIndex < rotatingListings.length ) {
120+
addUniqueListing( items, seenSlugs, rotatingListings[ rotatingIndex ] )
121+
rotatingIndex += 1
122+
}
123+
124+
if ( items.length < listSize ) {
125+
const remainingCategoryListings = device.buyerProfile.appCategories
126+
.flatMap( category => categoryPages[ category.slug ]?.items || [] )
127+
.filter( isAppListing )
128+
.sort( sortNewestFirst )
129+
130+
for ( const listing of remainingCategoryListings ) {
131+
addUniqueListing( items, seenSlugs, listing )
132+
133+
if ( items.length >= listSize ) break
134+
}
135+
}
136+
137+
return {
138+
items: items.slice( 0, listSize ),
139+
summary: null,
140+
previousPage: '',
141+
nextPage: ''
142+
}
143+
}
144+
145+
async function settleRequests ( requestMap ) {
146+
const entries = Object.entries( requestMap )
147+
const settled = await Promise.allSettled(
148+
entries.map( ( [ , request ] ) => request )
149+
)
150+
151+
return Object.fromEntries(
152+
settled.flatMap( ( result, index ) => {
153+
if ( result.status !== 'fulfilled' ) return []
154+
155+
return [[ entries[ index ][ 0 ], result.value ]]
156+
} )
157+
)
158+
}
159+
160+
export async function getCuratedDeviceAppPage ( device ) {
161+
const {
162+
DoesItAPI
163+
} = await import( './api/client.js' )
164+
const profile = device.buyerProfile
165+
const featuredRequests = Object.fromEntries(
166+
profile.featuredAppSlugs.map( slug => [
167+
slug,
168+
DoesItAPI.app( slug ).get()
169+
] )
170+
)
171+
const categoryRequests = Object.fromEntries(
172+
profile.appCategories.map( category => [
173+
category.slug,
174+
DoesItAPI.kind( category.slug )( 1 ).get()
175+
] )
176+
)
177+
const [
178+
featuredBySlug,
179+
categoryPages
180+
] = await Promise.all([
181+
settleRequests( featuredRequests ),
182+
settleRequests( categoryRequests )
183+
])
184+
185+
return makeCuratedDeviceAppPage({
186+
device,
187+
featuredListings: profile.featuredAppSlugs.flatMap( slug => {
188+
const listing = featuredBySlug[ slug ]
189+
190+
return listing ? [ listing ] : []
191+
} ),
192+
categoryPages
193+
})
194+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"cloudflare-deploy": "pnpm run cloudflare-build && pnpm exec wrangler deploy --config dist/server/wrangler.json",
6666
"verify:cloudflare:subscriptions": "node scripts/verify-subscriptions-worker.mjs",
6767
"verify:cloudflare:live": "node scripts/verify-cloudflare-frontend.mjs",
68+
"verify:device-curation": "pnpm exec vite-node scripts/audit-device-app-curation.js",
6869
"vercel-build": "pnpm exec vite-node scripts/vercel-build.js",
6970
"netlify-prebuild:download-sitemaps": "pnpm exec vite-node scripts/download-sitemaps.js",
7071
"netlify-prebuild:test-prebuild-functions": "pnpm test-prebuild && pnpm test-api-client && pnpm test-listings",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
getCuratedDeviceAppPage
3+
} from '~/helpers/device-app-curation.js'
4+
import {
5+
getListedDeviceListings
6+
} from '~/helpers/device-catalog.js'
7+
8+
const expectedListSize = 12
9+
const maximumSharedApps = 4
10+
11+
async function main () {
12+
const auditResults = []
13+
14+
for ( const device of getListedDeviceListings() ) {
15+
const page = await getCuratedDeviceAppPage( device )
16+
const appSlugs = page.items.map( listing => listing.slug )
17+
const appSlugSet = new Set( appSlugs )
18+
const categorySlugs = new Set(
19+
page.items.map( listing => listing.category?.slug )
20+
)
21+
const missingCategories = device.buyerProfile.appCategories
22+
.map( category => category.slug )
23+
.filter( slug => !categorySlugs.has( slug ) )
24+
25+
if ( appSlugs.length !== expectedListSize ) {
26+
throw new Error(
27+
`${ device.slug } returned ${ appSlugs.length } apps; expected ${ expectedListSize }`
28+
)
29+
}
30+
31+
if ( appSlugSet.size !== appSlugs.length ) {
32+
throw new Error( `${ device.slug } contains duplicate apps` )
33+
}
34+
35+
if ( missingCategories.length > 0 ) {
36+
throw new Error(
37+
`${ device.slug } is missing categories: ${ missingCategories.join( ', ' ) }`
38+
)
39+
}
40+
41+
auditResults.push({
42+
device: device.slug,
43+
categories: device.buyerProfile.appCategories.map( category => category.slug ),
44+
apps: appSlugs
45+
})
46+
}
47+
48+
for ( const [ leftIndex, leftResult ] of auditResults.entries() ) {
49+
const leftSlugs = new Set( leftResult.apps )
50+
51+
for ( const rightResult of auditResults.slice( leftIndex + 1 ) ) {
52+
const sharedApps = rightResult.apps
53+
.filter( slug => leftSlugs.has( slug ) )
54+
55+
if ( sharedApps.length > maximumSharedApps ) {
56+
throw new Error(
57+
`${ leftResult.device } and ${ rightResult.device } share ${ sharedApps.length } apps: ${ sharedApps.join( ', ' ) }`
58+
)
59+
}
60+
}
61+
}
62+
63+
console.log( JSON.stringify( auditResults, null, 2 ) )
64+
}
65+
66+
main().catch( error => {
67+
console.error( error )
68+
process.exit( 1 )
69+
} )

0 commit comments

Comments
 (0)