Skip to content

Instantly share code, notes, and snippets.

@tatethurston
Created January 2, 2026 21:00
Show Gist options
  • Select an option

  • Save tatethurston/87434fef1f614b38e1b0c4a6ad61edf8 to your computer and use it in GitHub Desktop.

Select an option

Save tatethurston/87434fef1f614b38e1b0c4a6ad61edf8 to your computer and use it in GitHub Desktop.
Lyft Bike Monthly Receipt Aggregator
class Month {
static previous(date) {
return this.month(date, { offset: -1 });
}
static current(date) {
return this.month(date, { offset: 0 });
}
static next(date) {
return this.month(date, { offset: 1 });
}
static month(date, { offset }) {
return new Date(
date.getFullYear(),
date.getMonth() + offset,
1,
0, 0, 0, 0
)
}
}
class LyftQuery {
constructor({ after, before }) {
this.query = [
'from:no-reply@lyftmail.com',
'subject:receipt',
`after:${this.format(after)}`,
`before:${this.format(before)}`
].join(' ');
}
format(date) {
return Utilities.formatDate(date, 'PST', 'yyyy/MM/dd');
}
search() {
Logger.log(`Searching "${this.query}"`);
const threads = GmailApp.search(this.query);
return threads;
}
}
class LyftEmailParser {
static normalize(html) {
return html
.replace(/=\r?\n/g, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\s+/g, ' ');
}
static extractBikeRides(message) {
const html = this.normalize(message.getBody())
const rides = [];
const rideRegex =
/(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}\s+(AM|PM)[\s\S]*?\$([\d.]+)[\s\S]*?Bike\/scooter fare/gi;
let match;
while ((match = rideRegex.exec(html)) !== null) {
rides.push({
amount: Number(match[3]),
date: match[0].match(
/(January|February|March|April|May|June|July|August|September|October|November|December)[^<]+/
)[0],
});
}
return {
message,
rides
};
}
static isWeekend(ride) {
const day = new Date (ride.rides[0].date);
return [0,6].includes(day.getDay());
}
static parse(threads) {
const total = threads
.flatMap(thread => thread.getMessages())
.map(message => this.extractBikeRides(message))
const rides = total.filter(ride => {
if (this.isWeekend(ride)) {
Logger.log(`Omitting ride because it fell on a weekend: ${JSON.stringify(ride)}`);
return false;
}
return true;
});
const validRides = rides.reduce((acc, cur) => acc + cur.rides.length, 0);
const totalRides = total.reduce((acc, cur) => acc + cur.rides.length, 0);
Logger.log(`Using ${validRides} / ${totalRides} rides`);
return rides;
}
static summary(messages) {
return messages.flatMap(message => message.rides)
.reduce((acc, cur) => acc + cur.amount, 0)
}
static consolidated(messages, date) {
const content = messages.sort((a, b) => b.message.getDate() - a.message.getDate());
const month = Utilities.formatDate(date, 'PST', 'MMM YYYY');
const html = [
`<body>
<h1>Consolidated Statement</h1>
<h2>${month}</h2>
<h2>Total: ${this.summary(messages)}</h2>
</body>`,
...content.map(message => message.message.getBody())
].join('');
return html;
}
}
class EmailGateway {
static send(html) {
const blob = Utilities
.newBlob(html, 'text/html')
.getAs('application/pdf')
.setName(`Lyft_Bike_Receipts.pdf`);
MailApp.sendEmail({
to: Session.getActiveUser().getEmail(),
subject: 'Lyft Bike Receipts',
body: Logger.getLog(),
attachments: [blob]
});
}
}
function main() {
const today = new Date('12/01/2025');
const query = new LyftQuery({
after: Month.previous(today),
before: Month.current(today),
});
threads = query.search();
Logger.log(`Found ${threads.length} emails`);
const messages = LyftEmailParser.parse(threads);
const html = LyftEmailParser.consolidated(messages, Month.previous(today));
EmailGateway.send(html)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment