/* pubspec.yaml: name: form_analysis dependencies: csv: any environment: sdk: '>=2.12.0-0 <3.0.0' */ // main.dart: import 'dart:io'; import 'package:csv/csv.dart'; enum Satisfaction { none, veryDissatisfied, somewhatDissatisfied, neither, somewhatSatisfied, verySatisfied } Satisfaction parseSatisfaction(String input) { switch (input) { case '': return Satisfaction.none; case 'Very dissatisfied': return Satisfaction.veryDissatisfied; case 'Somewhat dissatisfied': return Satisfaction.somewhatDissatisfied; case 'Neither satisfied nor dissatisfied': return Satisfaction.neither; case 'Somewhat satisfied': return Satisfaction.somewhatSatisfied; case 'Very satisfied': return Satisfaction.verySatisfied; default: throw FormatException('Unrecognized satisfaction: "$input"'); } } class SatisfactionSummary { SatisfactionSummary(this.vsat, this.csat); factory SatisfactionSummary.from(Iterable input) { int vsat = 0; int csat = 0; int all = 0; for (Satisfaction satisfaction in input) { switch (satisfaction) { case Satisfaction.none: break; case Satisfaction.veryDissatisfied: all += 1; break; case Satisfaction.somewhatDissatisfied: all += 1; break; case Satisfaction.neither: all += 1; break; case Satisfaction.somewhatSatisfied: all += 1; csat += 1; break; case Satisfaction.verySatisfied: all += 1; csat += 1; vsat += 1; break; } } return SatisfactionSummary(vsat/all, csat/all); } final double vsat; final double csat; String toString() { return 'CSAT ${(csat * 100).toStringAsFixed(1).padLeft(5)}% VSAT ${(vsat * 100).toStringAsFixed(1).padLeft(5)}%'; } } double median(List values) { values.sort(); if (values.length % 2 == 1) return values[values.length ~/ 2]; return (values[values.length ~/ 2] + values[values.length ~/ 2 - 1]) / 2.0; } class MultichoiceSummary { MultichoiceSummary(this.answers, this.count, this.noAnswerCount, this.medianAnswers); factory MultichoiceSummary.from(Iterable> input) { final Map answers = {}; List counts = []; int count = 0; int noAnswerCount = 0; for (Set entry in input) { if (entry.isEmpty) noAnswerCount += 1; for (String value in entry) { answers.putIfAbsent(value, () => 0); answers[value] = answers[value]! + 1; } counts.add(entry.length.toDouble()); count += 1; } return MultichoiceSummary(answers, count, noAnswerCount, median(counts)); } final Map answers; final int count; final int noAnswerCount; final double medianAnswers; Set get keys => answers.keys.toSet(); String describe(String key) => '${(100.0 * (answers[key] ?? 0.0) / count).toStringAsFixed(1).padLeft(5, ' ')}%'; String describeNoResponse() => '${(100.0 * noAnswerCount / count).toStringAsFixed(1).padLeft(5, ' ')}%'; String toString() { final StringBuffer buffer = StringBuffer(); final List values = answers.keys.toList(); values.sort((String a, String b) => answers[b]!.compareTo(answers[a]!)); for (String value in values) { buffer.writeln('${(100.0 * answers[value]! / count).toStringAsFixed(1).padLeft(6, ' ')}% $value'); } buffer.writeln('${(100.0 * noAnswerCount / count).toStringAsFixed(1).padLeft(6, ' ')}% (no response)'); buffer.writeln('Median number of answers per respondent: ${medianAnswers.toStringAsFixed(1)}'); return buffer.toString(); } } Iterable canonicalize(String input, Map> patterns) sync* { final String lowerInput = input.toLowerCase(); for (Pattern pattern in patterns.keys) { if (lowerInput.contains(pattern)) { print('Treating "$input" as "${patterns[pattern]!.join('", "')}".'); yield* patterns[pattern]!; return; } } yield input; } String typoFix(String input, Map? typoFixes) { if (typoFixes != null) { if (typoFixes.containsKey(input)) return typoFixes[input]!; } return input; } Iterable expect(String full, List options, { Map>? extraPatterns, Map? typoFixes, bool verbose = false }) sync* { String input = full; for (String option in options) { if (input == option) { yield typoFix(option, typoFixes); return; } else if (input.startsWith('$option, ')) { yield typoFix(option, typoFixes); input = input.substring(option.length + 2); } } if (input.isNotEmpty) { if (input.contains(',')) if (verbose) print('got: $input (in $full)'); if (extraPatterns != null) { yield* canonicalize(input, extraPatterns); } else { yield typoFix(input, typoFixes); } } } class Answer { static Answer from(List raw) { return Answer( timestamp: raw[0], howGroupToday: Set.from(expect( raw[1], // How do you communicate with other Flutter contributors as a group today? (select all those that you use regularly) [ 'Discord (the official Flutter Discord)', 'E-mail', 'Facebook', 'GitHub', 'Non-public corporate chat system', 'Twitter', 'WeChat', 'Weekly workshops', ], extraPatterns: const >{ 'i only communicate with other flutter contributor via certain issues': ['GitHub'], 'slack': ['Slack'], 'gitter': ['Gitter'], 'i very much hope to participate in community discussions, but discord is not accessible in china.': [_kNoDiscordInChina], // gets moved to discordPain in constructor }, typoFixes: const { 'Weekly workshops': 'Workshops', }, )), howDirectToday: Set.from(expect( raw[8], // How do you communicate with other Flutter contributors directly (1:1) today? (select all those that you use regularly) [ 'Discord (DMs or 1:1 chats in public chat channels)', 'E-mail', 'Facebook', 'GitHub', 'Public Google Hangouts', 'Non-public corporate chat system', 'Twitter', ], extraPatterns: const >{ 'slack': ['Slack'], 'weekly workshops,linkedin': ['Workshops', 'LinkedIn'], 'linkedin': ['LinkedIn'], 'i only talk to contributor over certain issue on github issue': ['GitHub'], 'nope': [], 'i found no way to communicate': [], }, )), discordPain: Set.from(expect( raw[2], // What are the biggest pain points of our Discord chat channels? (select all that apply) [ 'Lack of threading', 'Knowing the chats are public', 'Not wanting to have multiple chat systems open at once', 'The atmosphere on our Discord channels', 'Discord itself', 'Groups I need to talk to (e.g. specific customers) aren\'t on our Discord', 'Knowing the chats are public', 'Not wanting to have multiple chat systems open at once', 'The atmosphere on our Discord channels', 'Inability to mark oneself as being unavailable', ], extraPatterns: >{ 'slow': ['Slow mode'], 'nothing really': [], 'cannot direct message effectively': ['Lack of good DM solution'], 'dm on discord is discouraged, so talking with someone 1:1 has to be done in public, with the concern of taking up public channel. also people tend to disabling discord notification due to various reasons': ['Knowing the chats are public', 'Lack of good DM solution', 'People disable Discord notifications'], 'whack a mole': ['Not wanting to have multiple chat systems open at once', 'Too many channels'], 'threading is by far #1, but also lack of 1:1 and ad hoc group chats without being "friends", and lack of integration with google meet': ['Lack of threading', 'Lack of convenient ad-hoc group chats', 'Lack of Google Meet integration'], 'i never heard about discord or that it is used for flutter before': [], // redundant with "I was not aware of the Discord chat channels" below 'fragmentation of information: there are so many channels, its time consuming to monitor them all': ['Too many channels'], 'i haven\'t used it so i don\'t think i should give any opinions on this...': [], 'idiots tagging me': ['Too many unwanted notifications'], 'not receiving responses because people don\'t @-mention me (related to lack of threads)': ['Lack of threading', 'Too few notifications'], 'what is discord?': [], // redundant with "I was not aware of the Discord chat channels" below 'didn\'t have any till now': [], }, )), discordPMs: Set.from(expect( raw[9], // What has your experience with Discord DMs (direct messages, i.e. 1:1 communication) been? (select all that apply) [ 'I have used Discord DMs successfully', 'I am avoiding Discord DMs as recommended by go/flutter-policy or https://github.com/flutter/flutter/wiki/Chat#policies', 'I cannot use Discord DMs because I have confidential matters to discuss; I must use corporate chat systems', 'I cannot use Discord DMs because the people I want to talk to have it disabled', 'I cannot enable Discord DMs because I get random people asking questions all the time', 'I cannot enable Discord DMs because I get abuse', 'Having to use different chat systems for group chats and private chats is a significant pain point that drives me away from Discord', ], extraPatterns: >{ 'most people are available via internal chat, so there\'s insufficient motivation to learn how dms work (e.g. i\'m assuming (perhaps incorrectly) that one must be "friends" before dming)': ['I am avoiding Discord DMs because I already use another chat system successfully'], 'i have never used discord': [], 'my use of discord dms has been for non-flutter purposes': [], 'i have had dm on since we started using discord, and i\'ve had exactly one person try to dm me, and it was a pleasant experience.': ['I have used Discord DMs successfully'], 'me and other moderators of r/flutterdev frequently get unsolicited dms for help, but keep them open for the occasional reports of abuse and spam': ['I cannot enable Discord DMs because I get random people asking questions all the time'], // not quite the same but close enough '"your message could not be delivered because you don\'t share a server with the recipient or you disabled direct messages on your shared server, recipient is only accepting direct messages from friends, or you were blocked by the recipient."': ['I cannot use Discord DMs because the people I want to talk to have it disabled'], }, )), discordHelpful: Set.from(expect( raw[7], // Have our Discord chat channels been helpful? (select all that apply) [ 'No, they have not been helpul', 'They have facilitated collaboration with other contributors', 'They have helped me contribute more effectively relative to other communication methods', 'They have helped me build and ship Flutter', 'I was not aware of the Discord chat channels', ], extraPatterns: >{ 'haven\'t used them': ['No, they have not been helpful'], 'they have facilitated collaboration with contributors that don\'t use the non-public internal chat': ['They have facilitated collaboration with other contributors'], 'they have helped me see the contributors at work': ['They have facilitated collaboration with other contributors'], 'it does not allow conversation in threads which make it difficult to chat 1:1 in public channels': [], // respondent also mentioned threading in the question where this answer is more appropritae }, typoFixes: { 'No, they have not been helpul': 'No, they have not been helpful', }, )), discordSatisfaction: parseSatisfaction(raw[4]), openNow: Set.from(expect( // What communication systems do you have open at the moment? (select all that apply) raw[10], [ 'Discord', 'E-mail', 'Facebook', 'Facebook messenger', 'Gitter', 'Google Chat', 'Google Hangouts', 'IRC', 'Reddit', 'Signal', 'Skype', 'Slack', 'Twitter', // things people have listed 'Telegram', 'Lark', 'WhatsApp', ], extraPatterns: const >{ 'slack': ['Slack'], 'wechat': ['WeChat'], }, )), howContribute: Set.from(expect( // How do you contribute to Flutter? (select all that apply) raw[5], [ 'I mostly contribute in the Issues database (e.g. filing issues, triage, finding steps to reproduce, etc)', 'I mostly contribute by helping people, e.g. on Discord', 'I have contributed 1 or 2 PRs this year', 'I have contributed more than 2 PRs this year', 'I am employed to contribute to Flutter', 'I work for Google', ], extraPatterns: const >{ '4-6 prs monthly': ['I have contributed more than 2 PRs this year'], 'i am starting to contribute to flutter': [], }, )), howToBringIntoConversation: raw[3].trim(), moreThoughts: raw[6].trim(), ); } Answer({ required this.timestamp, required this.howGroupToday, required this.howDirectToday, required this.discordPain, required this.discordPMs, required this.discordHelpful, required this.discordSatisfaction, required this.openNow, required this.howContribute, required this.howToBringIntoConversation, required this.moreThoughts, }) : id = _nextId += 1 { if (howGroupToday.contains(_kNoDiscordInChina)) { print('Moving answer to more appropriate question...'); howGroupToday.remove(_kNoDiscordInChina); discordPain.add(_kNoDiscordInChina); } if (howContribute.contains('I have contributed 1 or 2 PRs this year but none of them were merged')) { print('Removing redundant/contradictory answer...'); howContribute.remove('I have contributed 1 or 2 PRs this year'); // just in case } } static int _nextId = 0; static const _kNoDiscordInChina = 'Discord is unavailable in my region'; final int id; final String timestamp; final Set howGroupToday; final Set howDirectToday; final Set discordPain; final Set discordPMs; final Set discordHelpful; final Satisfaction discordSatisfaction; final Set openNow; final Set howContribute; final String howToBringIntoConversation; final String moreThoughts; bool get isGoogler => howContribute.contains('I work for Google'); bool get isActiveContributor { return howContribute.contains('I have contributed more than 2 PRs this year') || howContribute.contains('I mostly contribute in the Issues database (e.g. filing issues, triage, finding steps to reproduce, etc)') || howContribute.contains('I am employed to contribute to Flutter'); } } String wrap(String input, int width) { final StringBuffer buffer = StringBuffer(); List paragraphs = input.split('\n').map((String line) => line.trim()).toList(); for (String paragraph in paragraphs) { final List words = paragraph.split(' ').where((String word) => word.isNotEmpty).toList(); if (words.isEmpty) continue; int length = 0; for (String word in words) { if (length > 0 && length + word.length > width) { buffer.writeln(); length = 0; } else if (length > 0) { buffer.write(' '); } buffer.write(word); length += word.length + 1; } buffer.writeln(); buffer.writeln(); } return buffer.toString().trim(); } String indent(String input, String firstLine, String prefix) { List lines = input.split('\n'); if (lines.isEmpty) return ''; final StringBuffer buffer = StringBuffer(); buffer.writeln('$firstLine${lines.removeAt(0)}'); for (String line in lines) buffer.writeln('$prefix$line'); return buffer.toString(); } class Analysis { factory Analysis.from(List data, String cohort, String code) { return Analysis( cohort, code, data.length, SatisfactionSummary.from(data.map((Answer answer) => answer.discordSatisfaction)), MultichoiceSummary.from(data.map((Answer answer) => answer.howGroupToday)), MultichoiceSummary.from(data.map((Answer answer) => answer.howDirectToday)), MultichoiceSummary.from(data.map((Answer answer) => answer.discordPain)), MultichoiceSummary.from(data.map((Answer answer) => answer.discordPMs)), MultichoiceSummary.from(data.map((Answer answer) => answer.discordHelpful)), MultichoiceSummary.from(data.map((Answer answer) => answer.openNow)), MultichoiceSummary.from(data.map((Answer answer) => answer.howContribute)), ); } Analysis( this.cohort, this.code, this.count, this.satisfactionSummary, this.howGroupToday, this.howDirectToday, this.discordPain, this.discordPMs, this.discordHelpful, this.openNow, this.howContribute, ); final String cohort; final String code; final int count; final SatisfactionSummary satisfactionSummary; final MultichoiceSummary howGroupToday; final MultichoiceSummary howDirectToday; final MultichoiceSummary discordPain; final MultichoiceSummary discordPMs; final MultichoiceSummary discordHelpful; final MultichoiceSummary openNow; final MultichoiceSummary howContribute; } enum _CsvBufferMode { startOfFile, endOfLine, inLine } class CsvBuffer { CsvBuffer() { _buffer = StringBuffer(); } late StringBuffer _buffer; _CsvBufferMode _mode = _CsvBufferMode.startOfFile; void addCell(String value) { switch (_mode) { case _CsvBufferMode.startOfFile: break; case _CsvBufferMode.endOfLine: _buffer.writeln(); break; case _CsvBufferMode.inLine: _buffer.write(','); break; } _buffer.write(_escapeCsv(value)); _mode = _CsvBufferMode.inLine; } void addNumericCell(num? value) { addCell(value != null ? value.toString() : ''); } void addCells(Iterable value) { value.forEach(addCell); } void endRow() { if (_mode.index > _CsvBufferMode.endOfLine.index) _mode = _CsvBufferMode.endOfLine; } void addRow(Iterable value) { endRow(); addCells(value); endRow(); } String _escapeCsv(String input) { input = input .replaceAll(r'\', r'\\') .replaceAll(r'"', r'\"'); return '"$input"'; } @override String toString() => _buffer.toString(); } String generateCohortCsv(List analyses) { assert(analyses.isNotEmpty); final CsvBuffer buffer = CsvBuffer(); buffer.addRow(['Cohorts']); buffer.addRow(['Cohort', 'Number of respondents', 'Cohort code']); for (Analysis analysis in analyses) buffer.addRow([analysis.cohort, analysis.count.toString(), analysis.code]); return buffer.toString(); } typedef ComponentGetter = T Function(Analysis analysis); String generateSatisfactionSummaryTable(String header, List analyses, ComponentGetter componentGetter) { assert(analyses.isNotEmpty); StringBuffer buffer = StringBuffer(); buffer.writeln(header); for (Analysis analysis in analyses) buffer.writeln(' ${componentGetter(analysis).toString()} ${analysis.cohort} (${analysis.code})'); return buffer.toString(); } String generateSatisfactionSummaryCsv(String header, List analyses, ComponentGetter componentGetter) { assert(analyses.isNotEmpty); CsvBuffer buffer = CsvBuffer(); buffer.addRow([header]); buffer.addRow(['Cohort', 'Cohort code', 'CSAT', 'VSAT']); for (Analysis analysis in analyses) { SatisfactionSummary summary = componentGetter(analysis); buffer.addRow([analysis.cohort, analysis.code, summary.csat.toString(), summary.vsat.toString()]); } return buffer.toString(); } String generateMultichoiceSummaryTable(String header, List analyses, ComponentGetter componentGetter) { assert(analyses.isNotEmpty); StringBuffer buffer = StringBuffer(); buffer.writeln(header); buffer.write(' '); for (Analysis analysis in analyses) buffer.write('${analysis.code.padLeft(6, " ")} '); buffer.writeln(); List summaries = analyses.map(componentGetter).toList(); Set keys = {}; for (MultichoiceSummary summary in summaries) keys.addAll(summary.keys); List rowLabels = keys.toList()..sort((String a, String b) { return summaries.first.answers[b]!.compareTo(summaries.first.answers[a]!); }); for (String rowLabel in rowLabels) { buffer.write(' '); for (MultichoiceSummary summary in summaries) { buffer.write(summary.describe(rowLabel)); buffer.write(' '); } buffer.writeln(rowLabel); } buffer.write(' '); for (MultichoiceSummary summary in summaries) { buffer.write(summary.describeNoResponse()); buffer.write(' '); } buffer.writeln('(no response)'); buffer.write(' '); for (MultichoiceSummary summary in summaries) { buffer.write(summary.medianAnswers.toStringAsFixed(1).padLeft(6)); buffer.write(' '); } buffer.writeln('median number of answers per respondent'); return buffer.toString(); } String generateMultichoiceSummaryCsv(String header, List analyses, ComponentGetter componentGetter) { assert(analyses.isNotEmpty); final CsvBuffer buffer = CsvBuffer(); buffer.addRow([header]); buffer.addCell('Answer'); for (Analysis analysis in analyses) buffer.addCell(analysis.code); buffer.endRow(); List summaries = analyses.map(componentGetter).toList(); Set keys = {}; for (MultichoiceSummary summary in summaries) keys.addAll(summary.keys); List rowLabels = keys.toList()..sort((String a, String b) { return summaries.first.answers[b]!.compareTo(summaries.first.answers[a]!); }); for (String rowLabel in rowLabels) { buffer.addCell(rowLabel); for (MultichoiceSummary summary in summaries) { buffer.addNumericCell((summary.answers[rowLabel] ?? 0) / summary.count); } buffer.endRow(); } buffer.addCell('(no response)'); for (MultichoiceSummary summary in summaries) { buffer.addNumericCell(summary.noAnswerCount / summary.count); } buffer.endRow(); buffer.addCell('median number of answers per respondent'); for (MultichoiceSummary summary in summaries) { buffer.addNumericCell(summary.medianAnswers); } return buffer.toString(); } void main() { print('Parsing...'); List> raw = const CsvToListConverter().convert(File('data.csv').readAsStringSync().trimRight()); List headers = raw.removeAt(0).cast(); List data = raw.map(Answer.from).toList(); print(''); print('RESULTS'); print('======='); print(''); final List analyses = [ Analysis.from(data, 'All respondents', 'ALL'), Analysis.from(data.where((Answer answer) => answer.isActiveContributor).toList(), 'Active contributors', 'TEAM'), Analysis.from(data.where((Answer answer) => answer.isActiveContributor && answer.isGoogler).toList(), 'Googlers who contribute actively', 'GOOG'), Analysis.from(data.where((Answer answer) => answer.isActiveContributor && !answer.isGoogler).toList(), 'Non-Googlers who contribute actively', 'NONG'), Analysis.from(data.where((Answer answer) => !answer.isActiveContributor).toList(), 'Not active contributors', 'OTHER'), Analysis.from(data.where((Answer answer) => answer.isActiveContributor && answer.discordPain.contains('Groups I need to talk to (e.g. specific customers) aren\'t on our Discord')).toList(), 'Want to talk to people not on Discord', 'TTNOD'), ]; print('COHORTS'); for (Analysis analysis in analyses) print(' ${analysis.code} = ${analysis.cohort} (${analysis.count} respondents)'); print(''); print(generateSatisfactionSummaryTable(headers[4], analyses, (Analysis analysis) => analysis.satisfactionSummary)); print(generateMultichoiceSummaryTable(headers[1], analyses, (Analysis analysis) => analysis.howGroupToday)); print(generateMultichoiceSummaryTable(headers[8], analyses, (Analysis analysis) => analysis.howDirectToday)); print(generateMultichoiceSummaryTable(headers[2], analyses, (Analysis analysis) => analysis.discordPain)); print(generateMultichoiceSummaryTable(headers[9], analyses, (Analysis analysis) => analysis.discordPMs)); print(generateMultichoiceSummaryTable(headers[7], analyses, (Analysis analysis) => analysis.discordHelpful)); print(generateMultichoiceSummaryTable(headers[10], analyses, (Analysis analysis) => analysis.openNow)); print(generateMultichoiceSummaryTable(headers[5], analyses, (Analysis analysis) => analysis.howContribute)); print(''); print('CSV'); print(''); print(generateCohortCsv(analyses)); print(generateMultichoiceSummaryCsv(headers[1], analyses, (Analysis analysis) => analysis.howGroupToday)); print(generateMultichoiceSummaryCsv(headers[8], analyses, (Analysis analysis) => analysis.howDirectToday)); print(generateMultichoiceSummaryCsv(headers[2], analyses, (Analysis analysis) => analysis.discordPain)); print(generateMultichoiceSummaryCsv(headers[9], analyses, (Analysis analysis) => analysis.discordPMs)); print(generateMultichoiceSummaryCsv(headers[7], analyses, (Analysis analysis) => analysis.discordHelpful)); print(generateMultichoiceSummaryCsv(headers[10], analyses, (Analysis analysis) => analysis.openNow)); print(generateMultichoiceSummaryCsv(headers[5], analyses, (Analysis analysis) => analysis.howContribute)); print(generateSatisfactionSummaryCsv(headers[4], analyses, (Analysis analysis) => analysis.satisfactionSummary)); print('\nFREEFORM REPLIES:\n'); final List howToBringIntoConversation = data.map((Answer answer) => answer.howToBringIntoConversation).toList()..sort(); final List moreThoughts = data.map((Answer answer) => answer.moreThoughts).toList()..sort(); for (String reply in howToBringIntoConversation + moreThoughts) { if (reply.isNotEmpty) print(indent(wrap(reply, 72), ' ', ' ')); } print(''); print('OPEN COMMUNICATION APPS SETS (e.g. for deepvenn.com)'); print(''); Map> vennOpenNow = >{}; for (Answer answer in data) { for (String key in answer.openNow) { vennOpenNow.putIfAbsent(key, () => {}) .add(answer); } } List keys = vennOpenNow.keys.toList() ..sort((String a, String b) { return vennOpenNow[b]!.length - vennOpenNow[a]!.length; }); for (String key in keys) { print('$key'); for (Answer answer in vennOpenNow[key]!) print('${answer.id}'); print(''); } }